Flutter Task Manager

Build a Task Manager app with Clean Architecture, BLoC, and SQLite.

Introduction

30 Second Summary

Every app on your phone started small. Then features piled up, code tangled together, and developers wished they had built a solid foundation from the start.

In this project, you will build a Task Manager mobile app using Flutter's Clean Architecture pattern, BLoC state management, and dependency injection to create maintainable, testable code that scales.

What You'll Build

A Task Manager application with multiple screens, local data persistence using SQLite and Hive, and a professional folder structure that separates business logic from UI and data access.

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

  • A fully functional Task Manager app built with Clean Architecture layers (presentation, domain, data) running on Android or iOS.
  • Implemented BLoC state management with both Cubit (simple) and full BLoC (event-driven) patterns, wired through get_it dependency injection.
  • A complete test suite covering repository, use case, and BLoC layers with mock objects.
  • Secret Mission: A Statistics Dashboard with charts showing task completion rates, category distribution, and weekly productivity trends.

Are there any prerequisites?

This project assumes basic Dart language knowledge. You should be comfortable with variables, classes, async/await, and null safety.

No prior Flutter experience is required. Step 1 walks you through the full environment setup from scratch.

Before We Start

Before We Start...

Set Up Your Flutter Environment

Before you can build your Task Manager app, you need a working Flutter development environment.

In this step, you will install the Flutter SDK, create your project, and add the packages that power Clean Architecture, state management, and local storage.

In this step, get ready to:
  • Install and verify Flutter SDK 3.44.0.
  • Create the task_manager project and run it on an emulator or device.
  • Add all project dependencies to pubspec.yaml.
Install Flutter SDK and verify your environment

The Flutter SDK includes the Dart language runtime, the Flutter framework, and command-line tools for building and running mobile apps.

  • Check whether Flutter is already installed by running this command in your terminal:
flutter doctor

✔️ I see Flutter 3.44 or higher

Your Flutter SDK is up to date. Move on to the next substep below.

ⓧ I see an older version

You have Flutter installed, but it needs to be updated to 3.44.0 or higher.

  • Upgrade to the latest stable version by running:
flutter upgrade
  • After the upgrade completes, verify the new version by running:
flutter --version

You should see Flutter 3.44.0 (or higher) in the output.

What does flutter upgrade do?

This command fetches the most recent version of the Flutter SDK available on your current release channel (stable by default). It also updates all framework packages and tools.

ⓧ Command not found

Flutter is not installed on your machine yet. Follow the official installation guide to set it up.

  • Visit the Flutter installation page and follow the Quick Start guide for your operating system.
  • The guide walks you through downloading the SDK, adding it to your PATH, and setting up your IDE (VS Code or Android Studio).
  • Make sure to also install the Android SDK and set up an Android Emulator (or connect a physical device) as part of the guide.
  • Once installation is complete, run this command to verify everything is working:
flutter doctor

You should see checkmarks next to Flutter, Android toolchain, and at least one connected device.

What does flutter doctor check?

This command inspects your entire development environment. It verifies the Flutter SDK version, Dart SDK, Android toolchain, IDE plugins, and connected devices.

If you see warnings (yellow) for items like Chrome or Visual Studio, those are fine to ignore for this mobile project. The critical checks are Flutter, Android toolchain, and at least one connected device.

Seeing Android license errors?

If flutter doctor reports that Android licenses are not accepted, run flutter doctor --android-licenses and accept each license by typing y when prompted.

Help me fix flutter doctor errors

Create the Flutter project and run it

Now that your Flutter environment is healthy, you can scaffold the project. Flutter's create command generates a complete starter app with all the boilerplate files you need.

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

What does flutter create do?

This command generates a complete Flutter project structure including lib/main.dart (your app entry point), pubspec.yaml (dependency configuration), and platform-specific folders for Android and iOS.

  • Move into the new project folder by running:
cd task_manager
  • Confirm the project files exist by running:
ls

You should see folders like lib, android, ios, and files like pubspec.yaml in the output.

  • Launch the default app on your emulator or connected device by running:
flutter run

What should I see?

Flutter builds the app and deploys it to your connected device or running emulator. You should see the default Flutter counter app with a blue AppBar, a centered counter value, and a floating action button that increments the count.

The first build takes a few minutes. Subsequent builds are much faster thanks to hot reload.

No connected devices found?

Make sure your Android Emulator is running (launch it from Android Studio's Device Manager) or your physical device is connected via USB with developer mode enabled.

Run flutter devices to see all available devices. If the list is empty, start an emulator first.

Help me connect a device for Flutter

  • Once you confirm the app is running, press q in your terminal to stop the running app.
Add project dependencies to pubspec.yaml

The default Flutter project only includes the core framework. Your Task Manager needs additional packages for state management, dependency injection, databases, and testing.

  • Open the task_manager project in your code editor (VS Code or Android Studio).
  • Open the pubspec.yaml file in the project root.
  • Replace the entire contents of pubspec.yaml with the following:
name: task_manager
description: A Task Manager app built with Clean Architecture.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3
  get_it: ^7.6.4
  sqflite: ^2.3.0
  hive_ce: ^2.0.0
  hive_ce_flutter: ^2.0.0
  shared_preferences: ^2.2.2
  equatable: ^2.0.5
  dartz: ^0.10.1
  path: ^1.8.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.1
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true

What are these packages for?

  • flutter_bloc provides the BLoC pattern widgets (BlocProvider, BlocBuilder, BlocConsumer) for state management.
  • get_it is a service locator for dependency injection without code generation.
  • sqflite provides SQLite database access for structured task storage.
  • hive_ce and hive_ce_flutter are the Hive Community Edition packages for fast key-value caching.
  • shared_preferences stores simple user settings (theme, sort order).
  • equatable simplifies value equality for entities and states.
  • dartz provides the Either type for functional error handling in repositories.
  • mocktail creates mock objects for unit testing without code generation.
  • Save pubspec.yaml.
  • Install all the packages by running this command in your terminal:
flutter pub get

You should see a message confirming all packages were resolved and downloaded successfully.

Seeing dependency resolution errors?

Make sure you copied the entire pubspec.yaml contents exactly. YAML is sensitive to indentation, so each nested level must use exactly two spaces (not tabs).

If you see version conflicts, ensure your Flutter SDK is 3.44.0 or higher by running flutter --version.

Help me fix pubspec.yaml errors

✔️ Awesome, I've got everything!

Great. Make sure you have saved pubspec.yaml and that flutter pub get completed without errors.

ⓧ I'd like to double check the full code

Here is the complete pubspec.yaml file. Compare it with yours to make sure everything matches:

name: task_manager
description: A Task Manager app built with Clean Architecture.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3
  get_it: ^7.6.4
  sqflite: ^2.3.0
  hive_ce: ^2.0.0
  hive_ce_flutter: ^2.0.0
  shared_preferences: ^2.2.2
  equatable: ^2.0.5
  dartz: ^0.10.1
  path: ^1.8.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.1
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true

Your development environment is ready and your project has all the packages it needs. Next up, you will build the app shell with widgets and layouts to create the Task Manager's visual foundation.

Build the App Shell and Core Widgets

Your project has all its dependencies installed, but it's still showing the default Flutter counter app. Every real app starts with a shell that defines its visual identity and hosts its screens.

In this step, you'll replace the default counter with a proper app shell, build reusable widgets, and lay out a task list screen using Flutter's layout system.

In this step, get ready to:
  • Set up the presentation layer folder structure.
  • Create reusable stateless widgets for task cards and category chips.
  • Build a stateful TaskListScreen with scroll control and layout widgets.
Set up the presentation layer folders

Clean Architecture separates code into layers. The presentation layer holds everything the user sees and interacts with: screens, widgets, and state management.

  • Create the presentation layer directories by running these commands from your project root:
mkdir -p lib/presentation/screens lib/presentation/widgets
  • Confirm the folders exist by running:
ls lib/presentation

You should see screens and widgets listed in the output.

Why separate screens and widgets?

Screens are full-page views (like a task list or detail page). Widgets are smaller, reusable pieces (like a single card or chip).

Separating them makes it easy to find what you need and reuse widgets across multiple screens.

Create the TaskCard and CategoryChip widgets

Before building the main screen, you need the building blocks it displays. A StatelessWidget receives data through its constructor and renders it without tracking internal state.

  • Create a new file at lib/presentation/widgets/task_card.dart and paste the following code:
import 'package:flutter/material.dart';

class TaskCard extends StatelessWidget {
  final String title;
  final String priority;
  final String dueDate;
  final bool isCompleted;
  final VoidCallback onToggle;

  const TaskCard({
    super.key,
    required this.title,
    required this.priority,
    required this.dueDate,
    required this.isCompleted,
    required this.onToggle,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      child: ListTile(
        // Checkbox shows completion state and calls onToggle when tapped
        leading: Checkbox(value: isCompleted, onChanged: (_) => onToggle()),
        title: Text(
          title,
          // Strike through text when task is completed
          style: TextStyle(decoration: isCompleted ? TextDecoration.lineThrough : null),
        ),
        subtitle: Row(
          children: [
            // Priority flag with color matching priority level
            Icon(Icons.flag, size: 14, color: _priorityColor(priority)),
            const SizedBox(width: 4),
            Text(priority),
            const SizedBox(width: 8),
            // Due date with calendar icon
            const Icon(Icons.calendar_today, size: 14),
            const SizedBox(width: 4),
            Text(dueDate),
          ],
        ),
      ),
    );
  }

  // Maps priority string to a color for the flag icon
  Color _priorityColor(String priority) {
    switch (priority) {
      case 'High': return Colors.red;
      case 'Medium': return Colors.orange;
      default: return Colors.green;
    }
  }
}

What does this code do?

  • TaskCard is a StatelessWidget that takes five parameters via its constructor: a title, priority level, due date, completion flag, and a toggle callback.
  • The Checkbox reflects isCompleted and fires onToggle when tapped. Later, a BLoC will handle that event.
  • The _priorityColor helper maps priority strings to colors so the flag icon is red, orange, or green.
  • Save task_card.dart.

You won't see a visual change yet. The task list screen you build next will use this widget.

  • Create another file at lib/presentation/widgets/category_chip.dart and paste this code:
import 'package:flutter/material.dart';

class CategoryChip extends StatelessWidget {
  final String label;
  final Color color;

  const CategoryChip({super.key, required this.label, required this.color});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
      child: Chip(
        label: Text(label),
        // Tint the chip background with the category's color at low opacity
        backgroundColor: color.withOpacity(0.2),
        labelStyle: TextStyle(color: color),
      ),
    );
  }
}

What does this code do?

CategoryChip takes a label and a color, then renders a Material Chip with a tinted background. You'll use these chips as filter buttons at the top of the task list.

  • Save category_chip.dart.

You won't see a change yet. Both widgets come together in the next substep.

Build the TaskListScreen and app shell

A StatefulWidget manages internal state that can change over time. The task list screen needs a ScrollController to track scroll position, which makes it stateful.

  • Create a file at lib/presentation/screens/task_list_screen.dart and paste this code:
import 'package:flutter/material.dart';
import '../widgets/task_card.dart';
import '../widgets/category_chip.dart';

class TaskListScreen extends StatefulWidget {
  const TaskListScreen({super.key});

  @override
  State<TaskListScreen> createState() => _TaskListScreenState();
}

class _TaskListScreenState extends State<TaskListScreen> {
  // ScrollController lets us track and control the list's scroll position
  final ScrollController _scrollController = ScrollController();

  @override
  void dispose() {
    // Always dispose controllers to avoid memory leaks
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task Manager'),
        actions: [
          IconButton(icon: const Icon(Icons.bar_chart), onPressed: () {}),
        ],
      ),
      body: Column(
        children: [
          // Horizontal scrolling row of category filter chips
          SizedBox(
            height: 50,
            child: ListView(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 8),
              children: const [
                CategoryChip(label: 'All', color: Colors.purple),
                CategoryChip(label: 'Work', color: Colors.blue),
                CategoryChip(label: 'Personal', color: Colors.green),
              ],
            ),
          ),
          // Vertically scrolling list of task cards
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: 5,
              itemBuilder: (context, index) {
                return TaskCard(
                  title: 'Sample Task $index',
                  priority: 'High',
                  dueDate: '2024-12-31',
                  isCompleted: index % 2 == 0,
                  onToggle: () {},
                );
              },
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}

What does this code do?

  • Column stacks children vertically. The first child is a horizontal chip bar. The second is the scrollable task list.
  • ListView.builder lazily builds only the items visible on screen. This is efficient for long lists because off-screen items don't consume memory.
  • The dispose() method cleans up the ScrollController when the widget is removed from the tree. Forgetting this causes memory leaks.
  • The horizontal ListView uses placeholder CategoryChips. The vertical ListView.builder generates 5 placeholder TaskCards.
  • Save task_list_screen.dart.

One more file connects everything. The app shell defines the visual theme and tells Flutter which screen to show first.

  • Open lib/main.dart and replace its entire contents with this code:
import 'package:flutter/material.dart';
import 'presentation/screens/task_list_screen.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Task Manager',
      // Light theme using Material 3 color system
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6750A4),
          brightness: Brightness.light,
        ),
        useMaterial3: true,
      ),
      // Dark theme variant generated from the same seed color
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6750A4),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const TaskListScreen(),
    );
  }
}

What does this code do?

  • MaterialApp is the root widget for a Material Design app. It provides navigation, theming, and localization.
  • ColorScheme.fromSeed generates a full set of harmonious colors from a single seed color. The hex value 0xFF6750A4 is Material 3's default purple.
  • Both theme and darkTheme are defined so the app automatically adapts to the user's system-wide light/dark preference.
  • home: const TaskListScreen() sets the initial screen the user sees when the app launches.
  • Save main.dart.
  • In the terminal where your app is running, press R (capital R) to perform a hot restart.

Your emulator should now display the Task Manager screen with a purple app bar, three colored category chips scrolling horizontally, and five task cards with checkboxes and red priority flags.

Seeing a red error screen or compile error?

  • Check the import path in main.dart. It must be presentation/screens/task_list_screen.dart (relative to lib/).
  • Verify the folder structure matches: lib/presentation/screens/ and lib/presentation/widgets/. A misplaced file will cause an import failure.
  • Make sure every file is saved. Unsaved changes won't be picked up by the hot restart.

Why is my Flutter app showing a red error screen?

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file.

import 'package:flutter/material.dart';
import 'presentation/screens/task_list_screen.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Task Manager',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6750A4),
          brightness: Brightness.light,
        ),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6750A4),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const TaskListScreen(),
    );
  }
}
import 'package:flutter/material.dart';
import '../widgets/task_card.dart';
import '../widgets/category_chip.dart';

class TaskListScreen extends StatefulWidget {
  const TaskListScreen({super.key});

  @override
  State<TaskListScreen> createState() => _TaskListScreenState();
}

class _TaskListScreenState extends State<TaskListScreen> {
  final ScrollController _scrollController = ScrollController();

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task Manager'),
        actions: [
          IconButton(icon: const Icon(Icons.bar_chart), onPressed: () {}),
        ],
      ),
      body: Column(
        children: [
          SizedBox(
            height: 50,
            child: ListView(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 8),
              children: const [
                CategoryChip(label: 'All', color: Colors.purple),
                CategoryChip(label: 'Work', color: Colors.blue),
                CategoryChip(label: 'Personal', color: Colors.green),
              ],
            ),
          ),
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: 5,
              itemBuilder: (context, index) {
                return TaskCard(
                  title: 'Sample Task $index',
                  priority: 'High',
                  dueDate: '2024-12-31',
                  isCompleted: index % 2 == 0,
                  onToggle: () {},
                );
              },
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}
import 'package:flutter/material.dart';

class TaskCard extends StatelessWidget {
  final String title;
  final String priority;
  final String dueDate;
  final bool isCompleted;
  final VoidCallback onToggle;

  const TaskCard({
    super.key,
    required this.title,
    required this.priority,
    required this.dueDate,
    required this.isCompleted,
    required this.onToggle,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      child: ListTile(
        leading: Checkbox(value: isCompleted, onChanged: (_) => onToggle()),
        title: Text(
          title,
          style: TextStyle(decoration: isCompleted ? TextDecoration.lineThrough : null),
        ),
        subtitle: Row(
          children: [
            Icon(Icons.flag, size: 14, color: _priorityColor(priority)),
            const SizedBox(width: 4),
            Text(priority),
            const SizedBox(width: 8),
            const Icon(Icons.calendar_today, size: 14),
            const SizedBox(width: 4),
            Text(dueDate),
          ],
        ),
      ),
    );
  }

  Color _priorityColor(String priority) {
    switch (priority) {
      case 'High': return Colors.red;
      case 'Medium': return Colors.orange;
      default: return Colors.green;
    }
  }
}
import 'package:flutter/material.dart';

class CategoryChip extends StatelessWidget {
  final String label;
  final Color color;

  const CategoryChip({super.key, required this.label, required this.color});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
      child: Chip(
        label: Text(label),
        backgroundColor: color.withOpacity(0.2),
        labelStyle: TextStyle(color: color),
      ),
    );
  }
}

Your app now has a proper visual shell with themed colors and a clean layout of reusable widgets. Next, you'll wire up real data persistence by setting up SQLite to store tasks in a local database.

Set Up SQLite Local Storage

Your Task Manager app has a working UI shell, but the data disappears the moment you close the app. For tasks to persist between sessions, you need a local database that stores them on the device.

In this step, you will create a SQLite database layer using the sqflite package. This gives your app structured, relational storage for tasks and categories with full Create, Read, Update, and Delete (CRUD) support.

In this step, get ready to:
  • Set up a DatabaseHelper singleton class with table creation.
  • Implement full CRUD operations with parameterized queries.
  • Add indexes on frequently queried columns for faster lookups.
Set up the database helper class

A database helper class centralizes all SQLite interactions into one place. Using the singleton pattern ensures only one database connection exists at any time, preventing data corruption from concurrent access.

  • Create the folder path for your data layer by running this command in your terminal (from the task_manager project root):
mkdir -p lib/data/datasources/local
  • Confirm the folder was created by running:
ls lib/data/datasources/local

You should see an empty directory listed with no errors.

  • Create a new file called database_helper.dart inside lib/data/datasources/local/.
  • Add the following code to set up the DatabaseHelper singleton, database initialization, and table creation:
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  // Singleton pattern ensures only one instance exists
  static final DatabaseHelper _instance = DatabaseHelper._internal();
  factory DatabaseHelper() => _instance;
  DatabaseHelper._internal();

  static Database? _database;

  // Lazy initialization: create the database only when first accessed
  Future<Database> get database async {
    _database ??= await _initDatabase();
    return _database!;
  }

  Future<Database> _initDatabase() async {
    // Get the device's default database directory
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'task_manager.db');
    return openDatabase(path, version: 1, onCreate: _onCreate);
  }

  Future<void> _onCreate(Database db, int version) async {
    // Create the tasks table with all required columns
    await db.execute('''
      CREATE TABLE tasks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        description TEXT,
        priority TEXT NOT NULL DEFAULT 'Medium',
        due_date TEXT,
        is_completed INTEGER NOT NULL DEFAULT 0,
        category_id INTEGER,
        created_at TEXT NOT NULL
      )
    ''');
    // Create the categories table for organizing tasks
    await db.execute('''
      CREATE TABLE categories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        color TEXT NOT NULL
      )
    ''');
  }
}

What does this code do?

The singleton pattern (_instance + factory constructor) guarantees every part of your app shares the same DatabaseHelper object. This prevents multiple database connections from stepping on each other.

The database getter uses lazy initialization with the ??= operator. It only calls _initDatabase() the first time, then reuses the same connection on every subsequent access.

The _onCreate callback runs exactly once when the database file is first created on the device. It defines two tables: tasks for storing task data and categories for grouping tasks.

  • Save database_helper.dart.

You won't see a visible change yet because the database is only created when it is first accessed at runtime.

Seeing import errors?

Make sure your pubspec.yaml includes sqflite and path in the dependencies section. If you added them recently, run flutter pub get to download the packages.

Help me fix sqflite import errors in my database_helper.dart file.

Implement full CRUD operations

With the database structure in place, you need methods to create, read, update, and delete records. Each method uses parameterized queries (the ? placeholder syntax) to prevent SQL injection.

  • In database_helper.dart, add the following task CRUD methods after the closing } of the _onCreate method (but still inside the DatabaseHelper class):
  Future<int> insertTask(Map<String, dynamic> task) async {
    final db = await database;
    return db.insert('tasks', task);
  }

  Future<List<Map<String, dynamic>>> queryAllTasks() async {
    final db = await database;
    return db.query('tasks', orderBy: 'created_at DESC');
  }

  Future<int> updateTask(Map<String, dynamic> task) async {
    final db = await database;
    return db.update('tasks', task, where: 'id = ?', whereArgs: [task['id']]);
  }

  Future<int> deleteTask(int id) async {
    final db = await database;
    return db.delete('tasks', where: 'id = ?', whereArgs: [id]);
  }

What does this code do?

Each method awaits the database getter to ensure the connection is initialized before running any queries.

The insertTask method returns the auto-generated row ID. The queryAllTasks method returns results ordered by newest first. Both updateTask and deleteTask use whereArgs for safe parameterized queries, preventing SQL injection attacks.

  • Now add the filtered query method below deleteTask. This builds dynamic WHERE clauses based on which filters are active:
  Future<List<Map<String, dynamic>>> queryTasksByFilter({
    int? categoryId, bool? isCompleted, String? priority,
  }) async {
    final db = await database;
    // Build dynamic filter conditions
    final conditions = <String>[];
    final args = <dynamic>[];
    if (categoryId != null) { conditions.add('category_id = ?'); args.add(categoryId); }
    if (isCompleted != null) { conditions.add('is_completed = ?'); args.add(isCompleted ? 1 : 0); }
    if (priority != null) { conditions.add('priority = ?'); args.add(priority); }
    final where = conditions.isEmpty ? null : conditions.join(' AND ');
    return db.query('tasks', where: where, whereArgs: args.isEmpty ? null : args, orderBy: 'created_at DESC');
  }

Why build dynamic filters?

The queryTasksByFilter method accepts optional parameters. It only adds a condition to the WHERE clause if that parameter is provided. This lets you filter by one criterion, multiple criteria, or no criteria at all with a single method.

Notice isCompleted converts the Dart bool to an integer (1 or 0) because SQLite does not have a native boolean type.

  • Finally, add the category CRUD methods below queryTasksByFilter:
  Future<int> insertCategory(Map<String, dynamic> category) async {
    final db = await database;
    return db.insert('categories', category);
  }

  Future<List<Map<String, dynamic>>> queryAllCategories() async {
    final db = await database;
    return db.query('categories');
  }

  Future<int> deleteCategory(int id) async {
    final db = await database;
    return db.delete('categories', where: 'id = ?', whereArgs: [id]);
  }

What does this code do?

These three methods handle category management. Categories are simpler than tasks because they only need insert, list, and delete operations. The queryAllCategories method returns all categories without ordering since the list is typically small.

  • Save database_helper.dart.

You won't see a runtime change yet because no screen is calling these methods. The next chunk connects them to the app.

Seeing a red underline on a method?

Check that every method is inside the DatabaseHelper class braces. A missing or extra } at the end of _onCreate can push new methods outside the class.

Help me fix red underlines in my DatabaseHelper CRUD methods.

Add indexing on frequently queried columns

As your task list grows, queries that filter or sort by specific columns become slower without indexes. An index tells SQLite to maintain a sorted lookup structure for a column, making filtered queries significantly faster.

  • In database_helper.dart, scroll to the _onCreate method.
  • Find the closing '); of the second db.execute (the one that creates the categories table).
  • Add these three index creation statements directly below it, before the closing } of _onCreate:
    await db.execute('CREATE INDEX idx_tasks_due_date ON tasks(due_date)');
    await db.execute('CREATE INDEX idx_tasks_category_id ON tasks(category_id)');
    await db.execute('CREATE INDEX idx_tasks_is_completed ON tasks(is_completed)');

Why these three columns?

The due_date index speeds up sorting tasks by deadline. The category_id index speeds up filtering tasks by category. The is_completed index speeds up separating done and pending tasks.

These are the columns your queryTasksByFilter method targets. Without indexes, SQLite would scan every row in the table for each filtered query.

  • Save database_helper.dart.
  • Run your app to confirm there are no compile errors by running:
flutter run

Your app should launch on the emulator without errors. The database file is not created until code first calls the database getter at runtime.

App fails to compile?

Check that your _onCreate method has the three CREATE INDEX statements after both CREATE TABLE statements, not inside them. Each db.execute call must be a separate await statement ending with a semicolon.

Help me fix compile errors in my database_helper.dart file.

✔️ Awesome, I've got everything!

Great. Double check you have saved database_helper.dart and that flutter run compiles without errors.

ⓧ I'd like to double check the full code

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static final DatabaseHelper _instance = DatabaseHelper._internal();
  factory DatabaseHelper() => _instance;
  DatabaseHelper._internal();

  static Database? _database;

  Future<Database> get database async {
    _database ??= await _initDatabase();
    return _database!;
  }

  Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'task_manager.db');
    return openDatabase(path, version: 1, onCreate: _onCreate);
  }

  Future<void> _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE tasks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        description TEXT,
        priority TEXT NOT NULL DEFAULT 'Medium',
        due_date TEXT,
        is_completed INTEGER NOT NULL DEFAULT 0,
        category_id INTEGER,
        created_at TEXT NOT NULL
      )
    ''');
    await db.execute('''
      CREATE TABLE categories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        color TEXT NOT NULL
      )
    ''');
    await db.execute('CREATE INDEX idx_tasks_due_date ON tasks(due_date)');
    await db.execute('CREATE INDEX idx_tasks_category_id ON tasks(category_id)');
    await db.execute('CREATE INDEX idx_tasks_is_completed ON tasks(is_completed)');
  }

  Future<int> insertTask(Map<String, dynamic> task) async {
    final db = await database;
    return db.insert('tasks', task);
  }

  Future<List<Map<String, dynamic>>> queryAllTasks() async {
    final db = await database;
    return db.query('tasks', orderBy: 'created_at DESC');
  }

  Future<List<Map<String, dynamic>>> queryTasksByFilter({
    int? categoryId, bool? isCompleted, String? priority,
  }) async {
    final db = await database;
    final conditions = <String>[];
    final args = <dynamic>[];
    if (categoryId != null) { conditions.add('category_id = ?'); args.add(categoryId); }
    if (isCompleted != null) { conditions.add('is_completed = ?'); args.add(isCompleted ? 1 : 0); }
    if (priority != null) { conditions.add('priority = ?'); args.add(priority); }
    final where = conditions.isEmpty ? null : conditions.join(' AND ');
    return db.query('tasks', where: where, whereArgs: args.isEmpty ? null : args, orderBy: 'created_at DESC');
  }

  Future<int> updateTask(Map<String, dynamic> task) async {
    final db = await database;
    return db.update('tasks', task, where: 'id = ?', whereArgs: [task['id']]);
  }

  Future<int> deleteTask(int id) async {
    final db = await database;
    return db.delete('tasks', where: 'id = ?', whereArgs: [id]);
  }

  Future<int> insertCategory(Map<String, dynamic> category) async {
    final db = await database;
    return db.insert('categories', category);
  }

  Future<List<Map<String, dynamic>>> queryAllCategories() async {
    final db = await database;
    return db.query('categories');
  }

  Future<int> deleteCategory(int id) async {
    final db = await database;
    return db.delete('categories', where: 'id = ?', whereArgs: [id]);
  }
}

Your data layer foundation is solid. Next up, you will add Hive caching on top of this SQLite storage so the app loads instantly from a local cache while syncing with the database in the background.

Add Hive Cache and SharedPreferences

Your SQLite database stores tasks reliably, but reading from disk every time the UI needs data introduces latency. A fast in-memory cache sitting in front of the database solves this problem.

In this step, you will add two more local storage layers to your data architecture. Hive CE provides a lightning-fast key-value cache for task objects, and SharedPreferencesWithCache stores lightweight user settings like sort order and theme mode.

In this step, get ready to:
  • Initialize Hive CE with type adapters and open cache boxes.
  • Build a HiveCacheDataSource class for reading and writing cached tasks.
  • Create an AppSettingsDataSource backed by SharedPreferencesWithCache.
Initialize Hive CE with type adapters

Hive stores custom Dart objects using type adapters. You define the shape of the cached data with annotations, then run code generation to produce the adapter class that handles serialization.

Before writing the cache data source, you need to add the code generation packages to your project.

  • Add build_runner and hive_ce_generator to your dev dependencies by running:
flutter pub add dev:build_runner dev:hive_ce_generator

Why dev dependencies?

Packages in dev_dependencies are only used during development (for code generation, testing, linting). They are not included in the compiled app binary, keeping your production build lean.

  • Create the cache data source file at lib/data/datasources/local/hive_cache_datasource.dart and add the CachedTask class with Hive annotations:
import 'package:hive_ce/hive.dart';

part 'hive_cache_datasource.g.dart';

@HiveType(typeId: 0)
class CachedTask extends HiveObject {
  @HiveField(0) final int id;
  @HiveField(1) final String title;
  @HiveField(2) final String? description;
  @HiveField(3) final String priority;
  @HiveField(4) final String? dueDate;
  @HiveField(5) final bool isCompleted;
  @HiveField(6) final int? categoryId;
  @HiveField(7) final String createdAt;

  CachedTask({
    required this.id, required this.title, this.description,
    required this.priority, this.dueDate, required this.isCompleted,
    this.categoryId, required this.createdAt,
  });
}

What does this code do?

The @HiveType(typeId: 0) annotation registers this class with Hive under a unique type ID. Each @HiveField annotation assigns a stable index to each property so Hive can serialize and deserialize the object.

The part 'hive_cache_datasource.g.dart' directive tells Dart that a generated file will extend this one. That generated file contains the CachedTaskAdapter class that Hive uses at runtime.

  • Generate the type adapter by running:
dart run build_runner build
  • You should see output indicating the build completed successfully. A new file hive_cache_datasource.g.dart appears in the same directory as your source file.

Build runner failed?

Make sure you saved hive_cache_datasource.dart before running the command. The generator reads the file from disk.

If you see a conflict error, run dart run build_runner build --delete-conflicting-outputs to overwrite stale generated files.

help me fix build_runner errors in my Flutter project

Now update main.dart to initialize Hive, register the generated adapter, and open the cache boxes before the app starts.

  • Replace the contents of lib/main.dart with the following:
import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'presentation/screens/task_list_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(CachedTaskAdapter());
  await Hive.openBox<CachedTask>('tasks_cache');
  await Hive.openBox('app_cache');
  runApp(const TaskManagerApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Task Manager',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.dark),
        useMaterial3: true,
      ),
      home: const TaskListScreen(),
    );
  }
}

What changed in main.dart?

The main() function is now async because Hive initialization requires awaiting. WidgetsFlutterBinding.ensureInitialized() is required before any plugin interaction in an async main.

Hive.initFlutter() sets up Hive's storage path on the device. Then the generated CachedTaskAdapter() is registered so Hive knows how to serialize CachedTask objects. Finally, two boxes are opened: a typed tasks_cache box for task objects and a general app_cache box for miscellaneous cached values.

  • Save main.dart.
  • Run your app with flutter run to verify it still launches without errors.

App crashes on startup?

If you see a HiveError about an unregistered adapter, confirm that the generated file hive_cache_datasource.g.dart exists and that main.dart imports hive_cache_datasource.dart (which pulls in the generated code via the part directive).

help me debug a HiveError about an unregistered type adapter

Build the HiveCacheDataSource class

With Hive initialized and the adapter generated, you can now build the data source class that reads and writes cached tasks. This class wraps the Hive box and provides clean methods the repository layer will call later.

  • In lib/data/datasources/local/hive_cache_datasource.dart, add the HiveCacheDataSource class below the CachedTask class:
class HiveCacheDataSource {
  final Box<CachedTask> _tasksBox;
  HiveCacheDataSource(this._tasksBox);

  List<CachedTask> getCachedTasks() => _tasksBox.values.toList();
  CachedTask? getCachedTask(int id) => _tasksBox.get(id);

  Future<void> cacheTasks(List<CachedTask> tasks) async {
    await _tasksBox.clear();
    await _tasksBox.putAll({for (var t in tasks) t.id: t});
  }

  Future<void> cacheTask(CachedTask task) async => _tasksBox.put(task.id, task);
  Future<void> deleteCachedTask(int id) async => _tasksBox.delete(id);
  Future<void> clearCache() async => _tasksBox.clear();
}

What does this code do?

HiveCacheDataSource takes an opened Box<CachedTask> as a constructor parameter. This makes it easy to inject the box during dependency injection setup later.

The cacheTasks method clears the entire box first, then inserts all tasks keyed by their ID. This ensures the cache always mirrors the latest full dataset from SQLite. Individual methods like cacheTask and deleteCachedTask handle single-item updates for the write-through strategy the repository will use.

  • Save hive_cache_datasource.dart.

Why a cache-first strategy?

Reading from an in-memory Hive box is nearly instant compared to querying SQLite. The repository (built in a later step) reads from the cache first. If the cache is empty, it falls back to SQLite, then populates the cache for next time.

On writes, the repository updates both SQLite and the cache simultaneously (write-through). This keeps the cache consistent without requiring a full reload.

Set up SharedPreferencesWithCache for user settings

User preferences like sort order and theme mode are simple key-value pairs that don't belong in a relational database. SharedPreferencesWithCache provides synchronous reads after an initial async load, which is ideal for settings the UI needs instantly.

  • Create a new file at lib/data/datasources/local/app_settings_datasource.dart and add the following code:
import 'package:shared_preferences/shared_preferences.dart';

class AppSettingsDataSource {
  final SharedPreferencesWithCache _prefs;
  AppSettingsDataSource(this._prefs);

  String get sortPreference => _prefs.getString('sort_preference') ?? 'created_at';
  Future<void> setSortPreference(String value) => _prefs.setString('sort_preference', value);

  String get themeMode => _prefs.getString('theme_mode') ?? 'system';
  Future<void> setThemeMode(String value) => _prefs.setString('theme_mode', value);

  int? get defaultCategoryId => _prefs.getInt('default_category_id');
  Future<void> setDefaultCategoryId(int value) => _prefs.setInt('default_category_id', value);
  Future<void> clearDefaultCategoryId() => _prefs.remove('default_category_id');
}

What does this code do?

AppSettingsDataSource wraps a SharedPreferencesWithCache instance. Getters are synchronous because the cache is already loaded at app startup. Setters are asynchronous because they write to both the cache and the platform storage.

Default values ('created_at' for sort, 'system' for theme) ensure the app works correctly on first launch before the user changes any settings.

What is SharedPreferencesWithCache?

Starting with shared_preferences 2.3.0, three APIs exist: the legacy SharedPreferences, SharedPreferencesAsync, and SharedPreferencesWithCache.

SharedPreferencesWithCache loads all preferences into memory once, then provides synchronous getters. This is the recommended approach for settings your UI reads frequently.

  • Save app_settings_datasource.dart.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: hive_cache_datasource.dart, main.dart, and app_settings_datasource.dart.

ⓧ I'd like to double check the full code

Here is the complete code for each file at the end of this step.

import 'package:hive_ce/hive.dart';

part 'hive_cache_datasource.g.dart';

@HiveType(typeId: 0)
class CachedTask extends HiveObject {
  @HiveField(0) final int id;
  @HiveField(1) final String title;
  @HiveField(2) final String? description;
  @HiveField(3) final String priority;
  @HiveField(4) final String? dueDate;
  @HiveField(5) final bool isCompleted;
  @HiveField(6) final int? categoryId;
  @HiveField(7) final String createdAt;

  CachedTask({
    required this.id, required this.title, this.description,
    required this.priority, this.dueDate, required this.isCompleted,
    this.categoryId, required this.createdAt,
  });
}

class HiveCacheDataSource {
  final Box<CachedTask> _tasksBox;
  HiveCacheDataSource(this._tasksBox);

  List<CachedTask> getCachedTasks() => _tasksBox.values.toList();
  CachedTask? getCachedTask(int id) => _tasksBox.get(id);

  Future<void> cacheTasks(List<CachedTask> tasks) async {
    await _tasksBox.clear();
    await _tasksBox.putAll({for (var t in tasks) t.id: t});
  }

  Future<void> cacheTask(CachedTask task) async => _tasksBox.put(task.id, task);
  Future<void> deleteCachedTask(int id) async => _tasksBox.delete(id);
  Future<void> clearCache() async => _tasksBox.clear();
}
import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'presentation/screens/task_list_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(CachedTaskAdapter());
  await Hive.openBox<CachedTask>('tasks_cache');
  await Hive.openBox('app_cache');
  runApp(const TaskManagerApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Task Manager',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.dark),
        useMaterial3: true,
      ),
      home: const TaskListScreen(),
    );
  }
}
import 'package:shared_preferences/shared_preferences.dart';

class AppSettingsDataSource {
  final SharedPreferencesWithCache _prefs;
  AppSettingsDataSource(this._prefs);

  String get sortPreference => _prefs.getString('sort_preference') ?? 'created_at';
  Future<void> setSortPreference(String value) => _prefs.setString('sort_preference', value);

  String get themeMode => _prefs.getString('theme_mode') ?? 'system';
  Future<void> setThemeMode(String value) => _prefs.setString('theme_mode', value);

  int? get defaultCategoryId => _prefs.getInt('default_category_id');
  Future<void> setDefaultCategoryId(int value) => _prefs.setInt('default_category_id', value);
  Future<void> clearDefaultCategoryId() => _prefs.remove('default_category_id');
}

Your local data layer now has three complementary storage systems: SQLite for structured relational data, Hive for fast in-memory caching, and SharedPreferences for lightweight user settings. Next up, you will build the BLoC state management layer that connects these data sources to your UI.

Implement BLoC State Management with Cubits

Your app has a working data layer with SQLite, Hive, and SharedPreferences. The next challenge is connecting that data to the UI in a way that's predictable, testable, and scalable.

The BLoC pattern solves this by separating business logic from widgets. In this step, you'll start with Cubit, BLoC's simpler cousin that uses direct method calls instead of events to trigger state changes.

In this step, get ready to:
  • Create a ThemeCubit for dark/light mode toggling.
  • Create a FilterCubit for task filtering state.
  • Wire both Cubits into the widget tree with MultiBlocProvider and BlocBuilder.
Create the ThemeCubit for dark/light mode

A Cubit holds a single piece of state and exposes methods that emit new states. The ThemeCubit will manage whether the app displays in light mode, dark mode, or follows the system setting.

  • Create the folder path for your first Cubit. In your terminal, run:
mkdir -p lib/presentation/cubits/theme
  • Confirm the folder exists by running:
ls lib/presentation/cubits/theme

The folder should appear empty, ready for your new file.

  • Create a new file called theme_cubit.dart inside lib/presentation/cubits/theme/ and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

// Immutable state class holding the current theme mode
class ThemeState extends Equatable {
  final ThemeMode themeMode;
  const ThemeState({this.themeMode = ThemeMode.system});
  ThemeState copyWith({ThemeMode? themeMode}) => ThemeState(themeMode: themeMode ?? this.themeMode);
  @override
  List<Object?> get props => [themeMode];
}

// Cubit that manages theme switching logic
class ThemeCubit extends Cubit<ThemeState> {
  ThemeCubit() : super(const ThemeState());
  void toggleTheme() {
    final next = state.themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
    emit(state.copyWith(themeMode: next));
  }
  void setTheme(ThemeMode mode) => emit(state.copyWith(themeMode: mode));
}

What does this code do?

ThemeState is an immutable class that holds a single ThemeMode value. It extends Equatable so BLoC can compare states by value instead of reference.

ThemeCubit extends Cubit<ThemeState> and starts with ThemeMode.system as the default. The toggleTheme() method flips between light and dark. The setTheme() method lets you jump to any specific mode. Both call emit() to push the new state to any listening widget.

  • Save theme_cubit.dart.
  • Check that your IDE shows no red underlines in the file. Equatable, flutter_bloc, and material should all resolve cleanly since you added these dependencies earlier.

Seeing unresolved import errors?

  • Make sure you ran flutter pub get after adding dependencies to pubspec.yaml. Packages are only available after fetching.
  • Verify the file is saved at the exact path lib/presentation/cubits/theme/theme_cubit.dart. A misplaced file won't resolve imports correctly.

Why are my Equatable imports not resolving?

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

class ThemeState extends Equatable {
  final ThemeMode themeMode;
  const ThemeState({this.themeMode = ThemeMode.system});
  ThemeState copyWith({ThemeMode? themeMode}) => ThemeState(themeMode: themeMode ?? this.themeMode);
  @override
  List<Object?> get props => [themeMode];
}

class ThemeCubit extends Cubit<ThemeState> {
  ThemeCubit() : super(const ThemeState());
  void toggleTheme() {
    final next = state.themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
    emit(state.copyWith(themeMode: next));
  }
  void setTheme(ThemeMode mode) => emit(state.copyWith(themeMode: mode));
}
Create the FilterCubit for task filtering

Your task list will support filtering by category, completion status, and priority. The FilterCubit holds these filter values so any widget in the tree can read or update them.

  • Create the folder for the FilterCubit by running:
mkdir -p lib/presentation/cubits/filter
  • Create a new file called filter_cubit.dart inside lib/presentation/cubits/filter/ and paste this code:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

// Holds the active filter criteria (all nullable = no filter applied)
class FilterState extends Equatable {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const FilterState({this.categoryId, this.isCompleted, this.priority});
  FilterState copyWith({int? categoryId, bool? isCompleted, String? priority}) =>
      FilterState(categoryId: categoryId ?? this.categoryId, isCompleted: isCompleted ?? this.isCompleted, priority: priority ?? this.priority);
  @override
  List<Object?> get props => [categoryId, isCompleted, priority];
}

// Cubit that manages filter selections
class FilterCubit extends Cubit<FilterState> {
  FilterCubit() : super(const FilterState());
  void setFilter({int? categoryId, bool? isCompleted, String? priority}) =>
      emit(state.copyWith(categoryId: categoryId, isCompleted: isCompleted, priority: priority));
  void clearFilter() => emit(const FilterState());
}

What does this code do?

FilterState holds three nullable fields. When a field is null, that filter is inactive. This lets you combine filters freely. For example, you could filter by priority alone or by priority plus completion status.

setFilter() uses copyWith() to update only the fields you pass in, preserving other active filters. clearFilter() resets everything to the default empty state.

  • Save filter_cubit.dart.
  • Verify that no red underlines appear in the file. The imports should resolve immediately.

Seeing a type error on copyWith?

  • Double check that all three fields in FilterState are nullable (use int?, bool?, String?). Non-nullable fields break the null-coalescing logic in copyWith.
  • Make sure the constructor uses const FilterState() with named optional parameters in curly braces.

Help me fix my FilterState copyWith method.

✔️ Awesome, I've got everything!

Great. Save the file and continue to the next substep.

ⓧ I'd like to double check the full code

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

class FilterState extends Equatable {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const FilterState({this.categoryId, this.isCompleted, this.priority});
  FilterState copyWith({int? categoryId, bool? isCompleted, String? priority}) =>
      FilterState(categoryId: categoryId ?? this.categoryId, isCompleted: isCompleted ?? this.isCompleted, priority: priority ?? this.priority);
  @override
  List<Object?> get props => [categoryId, isCompleted, priority];
}

class FilterCubit extends Cubit<FilterState> {
  FilterCubit() : super(const FilterState());
  void setFilter({int? categoryId, bool? isCompleted, String? priority}) =>
      emit(state.copyWith(categoryId: categoryId, isCompleted: isCompleted, priority: priority));
  void clearFilter() => emit(const FilterState());
}
Wire Cubits into the widget tree

Cubits only work when they're provided to the widget tree. MultiBlocProvider lets you inject multiple Cubits at the top of your app so every descendant widget can access them. BlocBuilder then rebuilds specific widgets whenever the Cubit emits a new state.

  • Open lib/main.dart and replace the entire file contents with this updated code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'presentation/cubits/theme/theme_cubit.dart';
import 'presentation/cubits/filter/filter_cubit.dart';
import 'presentation/screens/task_list_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(CachedTaskAdapter());
  await Hive.openBox<CachedTask>('tasks_cache');
  await Hive.openBox('app_cache');
  // Wrap the app in MultiBlocProvider to inject Cubits at the top of the tree
  runApp(
    MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => ThemeCubit()),
        BlocProvider(create: (_) => FilterCubit()),
      ],
      child: const TaskManagerApp(),
    ),
  );
}

class TaskManagerApp extends StatelessWidget {
  const TaskManagerApp({super.key});
  @override
  Widget build(BuildContext context) {
    // BlocBuilder rebuilds MaterialApp whenever ThemeCubit emits a new state
    return BlocBuilder<ThemeCubit, ThemeState>(
      builder: (context, themeState) {
        return MaterialApp(
          title: 'Task Manager',
          themeMode: themeState.themeMode,
          theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light), useMaterial3: true),
          darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.dark), useMaterial3: true),
          home: const TaskListScreen(),
        );
      },
    );
  }
}

What's happening here?

  • MultiBlocProvider wraps TaskManagerApp so both ThemeCubit and FilterCubit are available to every widget in the tree.
  • BlocBuilder<ThemeCubit, ThemeState> listens to ThemeCubit. When toggleTheme() is called anywhere, this builder re-runs and the themeMode on MaterialApp updates.
  • FilterCubit is now accessible to descendant widgets like TaskListScreen through context.read<FilterCubit>() or BlocBuilder<FilterCubit, FilterState>.

Why MultiBlocProvider instead of nested BlocProviders?

MultiBlocProvider flattens what would otherwise be deeply nested BlocProvider widgets into a readable list. As your app grows to 5 or more Cubits/BLoCs, the flat structure keeps your main() function easy to scan.

  • Save main.dart.
  • Run the app with hot restart by pressing Shift+R in your terminal (or stop and re-run with flutter run). The app should launch and display your TaskListScreen exactly as before.

The visual output is the same because ThemeCubit starts with ThemeMode.system, which matches the previous behaviour. The difference is that the theme is now driven by Cubit state instead of a hardcoded value.

App crashes on launch?

  • Check for a missing import. You need both import 'presentation/cubits/theme/theme_cubit.dart' and import 'presentation/cubits/filter/filter_cubit.dart' at the top of main.dart.
  • Make sure MultiBlocProvider wraps const TaskManagerApp() as its child. If you accidentally put it inside the MaterialApp, the Cubits won't be available.
  • Verify the file paths match exactly. The Cubit files must be at lib/presentation/cubits/theme/theme_cubit.dart and lib/presentation/cubits/filter/filter_cubit.dart.

Help me debug my MultiBlocProvider setup in main.dart.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before continuing.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'presentation/cubits/theme/theme_cubit.dart';
import 'presentation/cubits/filter/filter_cubit.dart';
import 'presentation/screens/task_list_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(CachedTaskAdapter());
  await Hive.openBox<CachedTask>('tasks_cache');
  await Hive.openBox('app_cache');
  runApp(
    MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => ThemeCubit()),
        BlocProvider(create: (_) => FilterCubit()),
      ],
      child: const TaskManagerApp(),
    ),
  );
}

class TaskManagerApp extends StatelessWidget {
  const TaskManagerApp({super.key});
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ThemeCubit, ThemeState>(
      builder: (context, themeState) {
        return MaterialApp(
          title: 'Task Manager',
          themeMode: themeState.themeMode,
          theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light), useMaterial3: true),
          darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.dark), useMaterial3: true),
          home: const TaskListScreen(),
        );
      },
    );
  }
}

Your app now has reactive state management. ThemeCubit controls the app's appearance and FilterCubit is ready for task filtering. Next up, you'll implement the full BLoC pattern with events and states for the core task operations.

Build the Full TaskBloc with Events and States

Your ThemeCubit and FilterCubit work perfectly for simple state changes like toggling a theme or setting a filter value. Task management is more complex.

Loading tasks from a database, adding new ones, handling errors, and filtering results are all async operations that can fail. A full BLoC with explicit events and states gives you fine-grained control over each operation's lifecycle.

In this step, get ready to:
  • Define TaskBloc events and states with Equatable.
  • Implement event handlers that emit loading, loaded, and error states.
  • Wire BlocConsumer into TaskListScreen for both UI rebuilds and side effects.
Create the TaskBloc event classes

In the full BLoC pattern, every user action or system trigger is modelled as an event object. The BLoC receives events, processes them, and emits new states.

  • Create the folder path lib/presentation/bloc/task/ inside your project by running:
mkdir -p lib/presentation/bloc/task
  • Create a new file called task_bloc.dart inside that folder.
  • Add the event classes by pasting this code into task_bloc.dart:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

// Base class for all task-related events
abstract class TaskEvent extends Equatable {
  const TaskEvent();
  @override List<Object?> get props => [];
}

// Triggered when the app needs to load tasks from storage
class LoadTasks extends TaskEvent {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const LoadTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

// Triggered when the user creates a new task
class AddTaskEvent extends TaskEvent {
  final Map<String, dynamic> taskData;
  const AddTaskEvent(this.taskData);
  @override List<Object?> get props => [taskData];
}

// Triggered when the user edits or toggles a task
class UpdateTaskEvent extends TaskEvent {
  final Map<String, dynamic> taskData;
  const UpdateTaskEvent(this.taskData);
  @override List<Object?> get props => [taskData];
}

// Triggered when the user deletes a task
class DeleteTaskEvent extends TaskEvent {
  final int id;
  const DeleteTaskEvent(this.id);
  @override List<Object?> get props => [id];
}

// Triggered when the user applies a filter combination
class FilterTasks extends TaskEvent {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const FilterTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

What do these events represent?

Each event class is a message the UI sends to the BLoC. LoadTasks fires when the screen first appears. AddTaskEvent, UpdateTaskEvent, and DeleteTaskEvent fire in response to user actions.

FilterTasks carries filter parameters so the BLoC can reload with new criteria. Equatable on each event enables value equality, which helps BLoC deduplicate identical events.

Define the TaskBloc state classes

States represent every possible condition the task list UI can be in. The UI switches its appearance based on which state the BLoC currently holds.

  • Add the state classes below the event classes in task_bloc.dart:
// Base class for all task-related states
abstract class TaskState extends Equatable {
  const TaskState();
  @override List<Object?> get props => [];
}

// Initial state before any loading has occurred
class TaskInitial extends TaskState {}

// Loading indicator state while fetching data
class TaskLoading extends TaskState {}

// Success state carrying the list of loaded tasks
class TaskLoaded extends TaskState {
  final List<Map<String, dynamic>> tasks;
  const TaskLoaded(this.tasks);
  @override List<Object?> get props => [tasks];
}

// Error state carrying the failure message
class TaskError extends TaskState {
  final String message;
  const TaskError(this.message);
  @override List<Object?> get props => [message];
}

Why four separate state classes?

TaskInitial means "nothing happened yet." TaskLoading tells the UI to show a spinner. TaskLoaded carries the actual task data for the list. TaskError carries a message the UI can display.

This pattern makes it impossible for the UI to show stale data during a load or silently swallow an error. Each state maps to exactly one visual outcome.

Implement the TaskBloc class with event handlers

The BLoC class registers a handler for each event type using on<EventType>. Each handler receives the event and an Emitter it uses to output new states.

  • Add the TaskBloc class below the state classes in task_bloc.dart:
class TaskBloc extends Bloc<TaskEvent, TaskState> {
  TaskBloc() : super(TaskInitial()) {
    // Register a handler for each event type
    on<LoadTasks>(_onLoadTasks);
    on<AddTaskEvent>(_onAddTask);
    on<UpdateTaskEvent>(_onUpdateTask);
    on<DeleteTaskEvent>(_onDeleteTask);
    on<FilterTasks>(_onFilterTasks);
  }

  // Load tasks from data source (placeholder until repository is wired)
  Future<void> _onLoadTasks(LoadTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  // Insert a new task into storage
  Future<void> _onAddTask(AddTaskEvent event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  // Update an existing task (toggle completion, edit fields)
  Future<void> _onUpdateTask(UpdateTaskEvent event, Emitter<TaskState> emit) async {
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  // Remove a task by its ID
  Future<void> _onDeleteTask(DeleteTaskEvent event, Emitter<TaskState> emit) async {
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  // Reload tasks with the given filter criteria
  Future<void> _onFilterTasks(FilterTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
}

Why are handlers returning empty lists?

These handlers are placeholders. The actual database queries will be wired in when you implement the Repository Pattern in a later step. For now, the BLoC structure is complete and the state machine works correctly.

The try/catch wrapping ensures that when real async operations are added, any exceptions automatically emit a TaskError state.

  • Save task_bloc.dart.

Seeing red underlines in your editor?

Make sure your imports at the top of the file include both flutter_bloc and equatable. If Equatable is not recognized, confirm that equatable is listed in your pubspec.yaml dependencies and run flutter pub get.

help me fix Equatable import errors in my task_bloc.dart file

✔️ Awesome, I've got everything!

Great. Double check you have saved task_bloc.dart with all three sections (events, states, and the TaskBloc class).

ⓧ I'd like to double check the full code

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

abstract class TaskEvent extends Equatable {
  const TaskEvent();
  @override List<Object?> get props => [];
}

class LoadTasks extends TaskEvent {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const LoadTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

class AddTaskEvent extends TaskEvent {
  final Map<String, dynamic> taskData;
  const AddTaskEvent(this.taskData);
  @override List<Object?> get props => [taskData];
}

class UpdateTaskEvent extends TaskEvent {
  final Map<String, dynamic> taskData;
  const UpdateTaskEvent(this.taskData);
  @override List<Object?> get props => [taskData];
}

class DeleteTaskEvent extends TaskEvent {
  final int id;
  const DeleteTaskEvent(this.id);
  @override List<Object?> get props => [id];
}

class FilterTasks extends TaskEvent {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const FilterTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

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<Map<String, dynamic>> tasks;
  const TaskLoaded(this.tasks);
  @override List<Object?> get props => [tasks];
}

class TaskError extends TaskState {
  final String message;
  const TaskError(this.message);
  @override List<Object?> get props => [message];
}

class TaskBloc extends Bloc<TaskEvent, TaskState> {
  TaskBloc() : super(TaskInitial()) {
    on<LoadTasks>(_onLoadTasks);
    on<AddTaskEvent>(_onAddTask);
    on<UpdateTaskEvent>(_onUpdateTask);
    on<DeleteTaskEvent>(_onDeleteTask);
    on<FilterTasks>(_onFilterTasks);
  }

  Future<void> _onLoadTasks(LoadTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  Future<void> _onAddTask(AddTaskEvent event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  Future<void> _onUpdateTask(UpdateTaskEvent event, Emitter<TaskState> emit) async {
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  Future<void> _onDeleteTask(DeleteTaskEvent event, Emitter<TaskState> emit) async {
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
  Future<void> _onFilterTasks(FilterTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    try { emit(const TaskLoaded([])); } catch (e) { emit(TaskError(e.toString())); }
  }
}
Update TaskListScreen with BlocConsumer

BlocConsumer combines a builder (for UI rebuilds) and a listener (for one-time side effects) in a single widget. The builder redraws the list based on state. The listener fires once per state change to show or clear SnackBars.

  • Replace the entire contents of lib/presentation/screens/task_list_screen.dart with this code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/task/task_bloc.dart';
import '../cubits/filter/filter_cubit.dart';
import '../widgets/task_card.dart';
import '../widgets/category_chip.dart';

class TaskListScreen extends StatefulWidget {
  const TaskListScreen({super.key});
  @override
  State<TaskListScreen> createState() => _TaskListScreenState();
}

class _TaskListScreenState extends State<TaskListScreen> {
  final ScrollController _scrollController = ScrollController();
  @override
  void dispose() { _scrollController.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task Manager'),
        actions: [IconButton(icon: const Icon(Icons.bar_chart), onPressed: () {})],
      ),
      body: Column(
        children: [
          // Horizontal filter chips row
          BlocBuilder<FilterCubit, FilterState>(
            builder: (context, filterState) {
              return SizedBox(
                height: 50,
                child: ListView(
                  scrollDirection: Axis.horizontal,
                  padding: const EdgeInsets.symmetric(horizontal: 8),
                  children: [
                    GestureDetector(
                      onTap: () {
                        context.read<FilterCubit>().clearFilter();
                        context.read<TaskBloc>().add(const LoadTasks());
                      },
                      child: CategoryChip(label: 'All', color: filterState.categoryId == null ? Colors.purple : Colors.grey),
                    ),
                  ],
                ),
              );
            },
          ),
          // Main task list area with BlocConsumer
          Expanded(
            child: BlocConsumer<TaskBloc, TaskState>(
              listener: (context, state) {
                // Side effect: clear old snackbars when tasks load successfully
                if (state is TaskLoaded) ScaffoldMessenger.of(context).clearSnackBars();
                // Side effect: show error message as a SnackBar
                if (state is TaskError) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
              },
              builder: (context, state) {
                if (state is TaskLoading) return const Center(child: CircularProgressIndicator());
                if (state is TaskError) return Center(child: Text(state.message));
                if (state is TaskLoaded) {
                  if (state.tasks.isEmpty) return const Center(child: Text('No tasks yet. Tap + to add one!'));
                  return ListView.builder(
                    controller: _scrollController,
                    itemCount: state.tasks.length,
                    itemBuilder: (context, index) {
                      final task = state.tasks[index];
                      return TaskCard(
                        title: task['title'] as String,
                        priority: task['priority'] as String,
                        dueDate: task['due_date'] as String? ?? '',
                        isCompleted: (task['is_completed'] as int) == 1,
                        onToggle: () => context.read<TaskBloc>().add(UpdateTaskEvent({...task, 'is_completed': (task['is_completed'] as int) == 1 ? 0 : 1})),
                      );
                    },
                  );
                }
                return const SizedBox.shrink();
              },
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)),
    );
  }
}

How does BlocConsumer work here?

The listener fires once per state change for side effects. When TaskError arrives, it shows a SnackBar. When TaskLoaded arrives, it clears any lingering error SnackBars.

The builder returns a different widget tree for each state: a spinner for TaskLoading, an error message for TaskError, and the task list for TaskLoaded. This separation keeps side effects out of the build method.

  • Save task_list_screen.dart.

Seeing an import error for task_bloc?

The import path ../bloc/task/task_bloc.dart is relative from the screens/ folder up to presentation/, then into bloc/task/. Confirm that task_bloc.dart exists at lib/presentation/bloc/task/task_bloc.dart.

help me fix my task_bloc.dart import path in TaskListScreen

✔️ Awesome, I've got everything!

Great. Make sure task_list_screen.dart is saved.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/task/task_bloc.dart';
import '../cubits/filter/filter_cubit.dart';
import '../widgets/task_card.dart';
import '../widgets/category_chip.dart';

class TaskListScreen extends StatefulWidget {
  const TaskListScreen({super.key});
  @override
  State<TaskListScreen> createState() => _TaskListScreenState();
}

class _TaskListScreenState extends State<TaskListScreen> {
  final ScrollController _scrollController = ScrollController();
  @override
  void dispose() { _scrollController.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task Manager'),
        actions: [IconButton(icon: const Icon(Icons.bar_chart), onPressed: () {})],
      ),
      body: Column(
        children: [
          BlocBuilder<FilterCubit, FilterState>(
            builder: (context, filterState) {
              return SizedBox(
                height: 50,
                child: ListView(
                  scrollDirection: Axis.horizontal,
                  padding: const EdgeInsets.symmetric(horizontal: 8),
                  children: [
                    GestureDetector(
                      onTap: () {
                        context.read<FilterCubit>().clearFilter();
                        context.read<TaskBloc>().add(const LoadTasks());
                      },
                      child: CategoryChip(label: 'All', color: filterState.categoryId == null ? Colors.purple : Colors.grey),
                    ),
                  ],
                ),
              );
            },
          ),
          Expanded(
            child: BlocConsumer<TaskBloc, TaskState>(
              listener: (context, state) {
                if (state is TaskLoaded) ScaffoldMessenger.of(context).clearSnackBars();
                if (state is TaskError) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
              },
              builder: (context, state) {
                if (state is TaskLoading) return const Center(child: CircularProgressIndicator());
                if (state is TaskError) return Center(child: Text(state.message));
                if (state is TaskLoaded) {
                  if (state.tasks.isEmpty) return const Center(child: Text('No tasks yet. Tap + to add one!'));
                  return ListView.builder(
                    controller: _scrollController,
                    itemCount: state.tasks.length,
                    itemBuilder: (context, index) {
                      final task = state.tasks[index];
                      return TaskCard(
                        title: task['title'] as String,
                        priority: task['priority'] as String,
                        dueDate: task['due_date'] as String? ?? '',
                        isCompleted: (task['is_completed'] as int) == 1,
                        onToggle: () => context.read<TaskBloc>().add(UpdateTaskEvent({...task, 'is_completed': (task['is_completed'] as int) == 1 ? 0 : 1})),
                      );
                    },
                  );
                }
                return const SizedBox.shrink();
              },
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)),
    );
  }
}
Wire TaskBloc into MultiBlocProvider

The last piece is providing TaskBloc to the widget tree so BlocConsumer can find it. You will also dispatch the initial LoadTasks event at creation time using the cascade operator.

  • In lib/main.dart, find this import line:
import 'presentation/cubits/theme/theme_cubit.dart';
  • Add the TaskBloc import directly above it, so your imports section looks like this:
import 'data/datasources/local/hive_cache_datasource.dart';
import 'presentation/bloc/task/task_bloc.dart';
import 'presentation/cubits/filter/filter_cubit.dart';
import 'presentation/cubits/theme/theme_cubit.dart';
import 'presentation/screens/task_list_screen.dart';
  • Next, find the providers list inside MultiBlocProvider. It currently has two items:
providers: [
  BlocProvider(create: (_) => ThemeCubit()),
  BlocProvider(create: (_) => FilterCubit()),
],
  • Add the TaskBloc provider as a third item so the list looks like this:
providers: [
  BlocProvider(create: (_) => ThemeCubit()),
  BlocProvider(create: (_) => FilterCubit()),
  BlocProvider(create: (_) => TaskBloc()..add(const LoadTasks())),
],

What does the cascade operator do here?

The ..add(const LoadTasks()) syntax uses Dart's cascade operator to call add on the TaskBloc immediately after creating it. This means the BLoC starts loading tasks the moment the app launches, so the UI never sits in the TaskInitial state with no data request in flight.

  • Save main.dart.
  • Hot restart your app (press R in the terminal running flutter run).
  • You should see the text "No tasks yet. Tap + to add one!" centered on screen.

App crashes on hot restart?

Check that the import path in main.dart exactly matches the file location: presentation/bloc/task/task_bloc.dart. A mismatched path causes an unresolved import crash.

Also confirm the providers list has a trailing comma after the TaskBloc line. Missing commas in Dart list literals produce confusing parse errors.

help me fix my main.dart after adding TaskBloc to MultiBlocProvider

✔️ Awesome, I've got everything!

Great. Confirm main.dart is saved and the app shows the empty task message.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'presentation/bloc/task/task_bloc.dart';
import 'presentation/cubits/filter/filter_cubit.dart';
import 'presentation/cubits/theme/theme_cubit.dart';
import 'presentation/screens/task_list_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(CachedTaskAdapter());
  await Hive.openBox<CachedTask>('tasks_cache');
  await Hive.openBox('app_cache');
  runApp(
    MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => ThemeCubit()),
        BlocProvider(create: (_) => FilterCubit()),
        BlocProvider(create: (_) => TaskBloc()..add(const LoadTasks())),
      ],
      child: const TaskManagerApp(),
    ),
  );
}

class TaskManagerApp extends StatelessWidget {
  const TaskManagerApp({super.key});
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ThemeCubit, ThemeState>(
      builder: (context, themeState) {
        return MaterialApp(
          title: 'Task Manager',
          themeMode: themeState.themeMode,
          theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light), useMaterial3: true),
          darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.dark), useMaterial3: true),
          home: const TaskListScreen(),
        );
      },
    );
  }
}

Your TaskBloc is wired in and responding to events with proper state transitions. Next up, you will build the domain layer with entities, use cases, and repository interfaces that give these placeholder handlers real data operations to call.

Build the Domain Layer with Entities and Use Cases

Your app currently has a working BLoC-driven UI and local data sources, but the business logic is tangled directly into the BLoC handlers. Clean Architecture solves this by isolating your business rules into a dedicated domain layer that has zero dependencies on Flutter, databases, or any external framework.

In this step, you will create pure Dart classes that represent your app's core concepts (entities), define contracts for data access (repository interfaces), and encapsulate individual operations (use cases). When this layer is done, you can test business logic without ever launching an emulator.

In this step, get ready to:
  • Create TaskEntity and CategoryEntity as pure business objects.
  • Define abstract repository interfaces with typed error handling.
  • Implement single-responsibility use cases for every task operation.
Create entities as pure business objects

Entities are the heart of your domain layer. They represent core business concepts with no knowledge of databases, JSON, or Flutter widgets.

  • Create the domain layer folder structure by running these commands from your project root:
mkdir -p lib/domain/entities lib/domain/repositories lib/domain/usecases lib/core/error

What do these folders represent?

The domain/entities folder holds pure business objects. domain/repositories holds abstract contracts that define what data operations exist, without saying how they work. domain/usecases holds single-purpose classes that each perform one business action.

The core/error folder holds shared error types used across all layers.

  • Confirm the folders exist by running:
ls lib/domain lib/core

You should see entities, repositories, and usecases listed under lib/domain, and error listed under lib/core.

  • Create lib/domain/entities/task_entity.dart and paste this code:
import 'package:equatable/equatable.dart';

class TaskEntity extends Equatable {
  final int? id;
  final String title;
  final String? description;
  final String priority;
  final String? dueDate;
  final bool isCompleted;
  final int? categoryId;
  final String createdAt;

  const TaskEntity({
    this.id, required this.title, this.description,
    required this.priority, this.dueDate, required this.isCompleted,
    this.categoryId, required this.createdAt,
  });

  TaskEntity copyWith({int? id, String? title, String? description, String? priority, String? dueDate, bool? isCompleted, int? categoryId, String? createdAt}) {
    return TaskEntity(
      id: id ?? this.id, title: title ?? this.title, description: description ?? this.description,
      priority: priority ?? this.priority, dueDate: dueDate ?? this.dueDate,
      isCompleted: isCompleted ?? this.isCompleted, categoryId: categoryId ?? this.categoryId,
      createdAt: createdAt ?? this.createdAt,
    );
  }

  @override
  List<Object?> get props => [id, title, description, priority, dueDate, isCompleted, categoryId, createdAt];
}

What does this code do?

TaskEntity is a pure Dart class that represents a task in your business logic. It extends Equatable so that two TaskEntity instances with the same field values are considered equal.

The copyWith method creates a new instance with some fields changed, which is essential for immutable state updates in BLoC. Notice there are no database annotations, no JSON methods. This class knows nothing about how data is stored.

  • Save task_entity.dart.
  • Create lib/domain/entities/category_entity.dart and paste this code:
import 'package:equatable/equatable.dart';

class CategoryEntity extends Equatable {
  final int? id;
  final String name;
  final String color;
  const CategoryEntity({this.id, required this.name, required this.color});
  @override List<Object?> get props => [id, name, color];
}

What does this code do?

CategoryEntity represents a task category with a name and color. Like TaskEntity, it extends Equatable for value equality and stays completely independent of storage or UI concerns.

  • Save category_entity.dart.

✔️ Awesome, I've got everything!

Great. Make sure both entity files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete task_entity.dart:

import 'package:equatable/equatable.dart';

class TaskEntity extends Equatable {
  final int? id;
  final String title;
  final String? description;
  final String priority;
  final String? dueDate;
  final bool isCompleted;
  final int? categoryId;
  final String createdAt;

  const TaskEntity({
    this.id, required this.title, this.description,
    required this.priority, this.dueDate, required this.isCompleted,
    this.categoryId, required this.createdAt,
  });

  TaskEntity copyWith({int? id, String? title, String? description, String? priority, String? dueDate, bool? isCompleted, int? categoryId, String? createdAt}) {
    return TaskEntity(
      id: id ?? this.id, title: title ?? this.title, description: description ?? this.description,
      priority: priority ?? this.priority, dueDate: dueDate ?? this.dueDate,
      isCompleted: isCompleted ?? this.isCompleted, categoryId: categoryId ?? this.categoryId,
      createdAt: createdAt ?? this.createdAt,
    );
  }

  @override
  List<Object?> get props => [id, title, description, priority, dueDate, isCompleted, categoryId, createdAt];
}

And the complete category_entity.dart:

import 'package:equatable/equatable.dart';

class CategoryEntity extends Equatable {
  final int? id;
  final String name;
  final String color;
  const CategoryEntity({this.id, required this.name, required this.color});
  @override List<Object?> get props => [id, name, color];
}
Define repository interfaces with typed error handling

Repository interfaces define what data operations your domain layer needs, without specifying how they are implemented. Before creating them, you need error types that express what can go wrong.

  • Create lib/core/error/exceptions.dart and paste this code:
class CacheException implements Exception {
  final String message;
  const CacheException(this.message);
}

class DatabaseException implements Exception {
  final String message;
  const DatabaseException(this.message);
}

What does this code do?

These are custom exception classes for the data layer. CacheException is thrown when Hive cache operations fail. DatabaseException is thrown when SQLite operations fail. They implement Dart's built-in Exception interface so they integrate with try/catch.

  • Save exceptions.dart.
  • Create lib/core/error/failures.dart and paste this code:
import 'package:equatable/equatable.dart';

abstract class Failure extends Equatable {
  final String message;
  const Failure(this.message);
  @override List<Object?> get props => [message];
}

class CacheFailure extends Failure {
  const CacheFailure(super.message);
}

class DatabaseFailure extends Failure {
  const DatabaseFailure(super.message);
}

What is the difference between exceptions and failures?

Exceptions are thrown by the data layer when something goes wrong at the implementation level (a database query fails, a cache read returns null). Failures are the domain layer's way of representing those errors.

The repository catches exceptions and converts them into Failure objects returned inside an Either type. This way, your use cases and BLoCs never deal with try/catch. They pattern-match on Left (failure) or Right (success) instead.

  • Save failures.dart.
  • Create lib/domain/repositories/task_repository.dart and paste this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';

abstract class TaskRepository {
  Future<Either<Failure, List<TaskEntity>>> getTasks({int? categoryId, bool? isCompleted, String? priority});
  Future<Either<Failure, int>> addTask(TaskEntity task);
  Future<Either<Failure, void>> updateTask(TaskEntity task);
  Future<Either<Failure, void>> deleteTask(int id);
}

What is Either and why use it here?

Either<Failure, T> from the dartz package represents a value that is either a Failure (left side) or a successful result (right side). This replaces traditional try/catch with explicit return types.

Because these methods are on an abstract class, you are only defining the contract. The concrete implementation (which talks to SQLite and Hive) will live in the data layer later.

  • Save task_repository.dart.
  • Create lib/domain/repositories/category_repository.dart and paste this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/category_entity.dart';

abstract class CategoryRepository {
  Future<Either<Failure, List<CategoryEntity>>> getCategories();
  Future<Either<Failure, int>> addCategory(CategoryEntity category);
  Future<Either<Failure, void>> deleteCategory(int id);
}

What does this code do?

CategoryRepository follows the same pattern as TaskRepository. It defines three operations (get, add, delete) for categories, each returning Either<Failure, T> so callers always know if something went wrong.

  • Save category_repository.dart.
  • Verify the domain layer compiles by running:
dart analyze lib/domain lib/core

You should see no errors in the output. Warnings about unused imports are fine at this stage.

Seeing import errors?

Make sure dartz and equatable are listed in your pubspec.yaml dependencies. If they are, run flutter pub get to make sure packages are downloaded.

Also check that relative import paths match your folder structure exactly. help me fix dartz or equatable import errors in my domain layer

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before continuing to use cases.

ⓧ I'd like to double check the full code

Here is lib/core/error/exceptions.dart:

class CacheException implements Exception {
  final String message;
  const CacheException(this.message);
}

class DatabaseException implements Exception {
  final String message;
  const DatabaseException(this.message);
}

Here is lib/core/error/failures.dart:

import 'package:equatable/equatable.dart';

abstract class Failure extends Equatable {
  final String message;
  const Failure(this.message);
  @override List<Object?> get props => [message];
}

class CacheFailure extends Failure {
  const CacheFailure(super.message);
}

class DatabaseFailure extends Failure {
  const DatabaseFailure(super.message);
}

Here is lib/domain/repositories/task_repository.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';

abstract class TaskRepository {
  Future<Either<Failure, List<TaskEntity>>> getTasks({int? categoryId, bool? isCompleted, String? priority});
  Future<Either<Failure, int>> addTask(TaskEntity task);
  Future<Either<Failure, void>> updateTask(TaskEntity task);
  Future<Either<Failure, void>> deleteTask(int id);
}

Here is lib/domain/repositories/category_repository.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/category_entity.dart';

abstract class CategoryRepository {
  Future<Either<Failure, List<CategoryEntity>>> getCategories();
  Future<Either<Failure, int>> addCategory(CategoryEntity category);
  Future<Either<Failure, void>> deleteCategory(int id);
}
Implement single-responsibility use cases

Each use case class encapsulates exactly one business operation. It takes a repository through its constructor and exposes a single call() method.

Why wrap one method in a whole class?

Use cases make your BLoC handlers thin. Instead of the BLoC knowing how to coordinate caches, databases, and error handling, it just calls a use case and reacts to the result.

Each use case is also independently testable. You can mock the repository and verify that the use case calls the right method with the right arguments.

  • Create lib/domain/usecases/get_tasks.dart and paste this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';

class GetTasksParams {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const GetTasksParams({this.categoryId, this.isCompleted, this.priority});
}

class GetTasks {
  final TaskRepository repository;
  const GetTasks(this.repository);
  Future<Either<Failure, List<TaskEntity>>> call(GetTasksParams params) =>
      repository.getTasks(categoryId: params.categoryId, isCompleted: params.isCompleted, priority: params.priority);
}

What does this code do?

GetTasksParams bundles the optional filter parameters into a single object. This keeps the call() method signature clean and makes it easy to add filters later without changing the method signature.

GetTasks receives a TaskRepository (the abstract interface, not a concrete class) and delegates to it. The use case itself adds no logic here, but in more complex apps it would orchestrate multiple repositories or apply business rules.

  • Save get_tasks.dart.
  • Create lib/domain/usecases/add_task.dart and paste this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';

class AddTask {
  final TaskRepository repository;
  const AddTask(this.repository);
  Future<Either<Failure, int>> call(TaskEntity task) => repository.addTask(task);
}
  • Save add_task.dart.
  • Create lib/domain/usecases/update_task.dart and paste this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';

class UpdateTask {
  final TaskRepository repository;
  const UpdateTask(this.repository);
  Future<Either<Failure, void>> call(TaskEntity task) => repository.updateTask(task);
}
  • Save update_task.dart.
  • Create lib/domain/usecases/delete_task.dart and paste this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../repositories/task_repository.dart';

class DeleteTask {
  final TaskRepository repository;
  const DeleteTask(this.repository);
  Future<Either<Failure, void>> call(int id) => repository.deleteTask(id);
}
  • Save delete_task.dart.
  • Create lib/domain/usecases/get_categories.dart and paste this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/category_entity.dart';
import '../repositories/category_repository.dart';

class GetCategories {
  final CategoryRepository repository;
  const GetCategories(this.repository);
  Future<Either<Failure, List<CategoryEntity>>> call() => repository.getCategories();
}

What is the pattern across all use cases?

Every use case follows the same structure: a final repository field, a const constructor, and a single call() method that delegates to the repository. Dart lets you invoke a class instance like a function when it has a call method, so your BLoC can write getTasks(params) instead of getTasks.call(params).

  • Save get_categories.dart.
  • Verify the entire domain layer compiles cleanly by running:
dart analyze lib/domain lib/core

You should see no errors. Your domain layer is now fully defined with zero dependencies on Flutter or any data source.

Seeing unresolved import errors?

Check that file names and folder paths match exactly. Dart imports are case-sensitive and must match the file system path precisely.

If you see errors about dartz not found, run flutter pub get to ensure all dependencies are resolved. help me debug dart analyze errors in my domain usecases

✔️ Awesome, I've got everything!

Great. Make sure all five use case files are saved.

ⓧ I'd like to double check the full code

Here is lib/domain/usecases/get_tasks.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';

class GetTasksParams {
  final int? categoryId;
  final bool? isCompleted;
  final String? priority;
  const GetTasksParams({this.categoryId, this.isCompleted, this.priority});
}

class GetTasks {
  final TaskRepository repository;
  const GetTasks(this.repository);
  Future<Either<Failure, List<TaskEntity>>> call(GetTasksParams params) =>
      repository.getTasks(categoryId: params.categoryId, isCompleted: params.isCompleted, priority: params.priority);
}

Here is lib/domain/usecases/add_task.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';

class AddTask {
  final TaskRepository repository;
  const AddTask(this.repository);
  Future<Either<Failure, int>> call(TaskEntity task) => repository.addTask(task);
}

Here is lib/domain/usecases/update_task.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/task_entity.dart';
import '../repositories/task_repository.dart';

class UpdateTask {
  final TaskRepository repository;
  const UpdateTask(this.repository);
  Future<Either<Failure, void>> call(TaskEntity task) => repository.updateTask(task);
}

Here is lib/domain/usecases/delete_task.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../repositories/task_repository.dart';

class DeleteTask {
  final TaskRepository repository;
  const DeleteTask(this.repository);
  Future<Either<Failure, void>> call(int id) => repository.deleteTask(id);
}

Here is lib/domain/usecases/get_categories.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/category_entity.dart';
import '../repositories/category_repository.dart';

class GetCategories {
  final CategoryRepository repository;
  const GetCategories(this.repository);
  Future<Either<Failure, List<CategoryEntity>>> call() => repository.getCategories();
}

Your domain layer is complete. Next up, you will build the data layer with concrete repository implementations that coordinate between SQLite and Hive, turning these abstract contracts into real data operations.

Implement the Data Layer with Models and Repositories

Your domain layer defines what data looks like (entities) and what operations are possible (use cases and repository interfaces). But the domain layer never touches a database or cache directly.

The data layer is where those abstract contracts become real. In this step, you will create model classes that convert between database rows and domain entities, then build concrete repository implementations that coordinate SQLite and Hive behind a single interface.

In this step, get ready to:
  • Create TaskModel and CategoryModel as data transfer objects with JSON conversion methods.
  • Build TaskRepositoryImpl with cache-first reads and write-through mutations.
  • Update TaskBloc to use domain use cases instead of direct data source calls.
Create the data models

Models are data transfer objects (DTOs) that sit between your raw database maps and your clean domain entities. Each model knows how to convert itself in both directions.

  • Create the file lib/data/models/category_model.dart and add the following code:
import '../../domain/entities/category_entity.dart';

class CategoryModel {
  final int? id;
  final String name;
  final String color;
  const CategoryModel({this.id, required this.name, required this.color});

  // Convert a database row (Map) into a CategoryModel
  factory CategoryModel.fromJson(Map<String, dynamic> json) =>
      CategoryModel(id: json['id'] as int?, name: json['name'] as String, color: json['color'] as String);

  // Convert this model back to a Map for database insertion
  Map<String, dynamic> toJson() => {if (id != null) 'id': id, 'name': name, 'color': color};

  // Bridge between domain entity and data model
  factory CategoryModel.fromEntity(CategoryEntity e) => CategoryModel(id: e.id, name: e.name, color: e.color);
  CategoryEntity toEntity() => CategoryEntity(id: id, name: name, color: color);
}

What does this code do?

CategoryModel has four factory/conversion methods that form a complete bridge between raw data and your domain layer.

  • fromJson takes a database row (a Map) and creates a model instance.
  • toJson converts the model back into a Map suitable for SQLite insertion.
  • fromEntity and toEntity bridge between the domain's pure CategoryEntity and this data-layer model.
  • Save category_model.dart.
  • Now create lib/data/models/task_model.dart and add this code:
import '../../domain/entities/task_entity.dart';

class TaskModel {
  final int? id;
  final String title;
  final String? description;
  final String priority;
  final String? dueDate;
  final bool isCompleted;
  final int? categoryId;
  final String createdAt;

  const TaskModel({this.id, required this.title, this.description, required this.priority, this.dueDate, required this.isCompleted, this.categoryId, required this.createdAt});

  // Convert a SQLite row into a TaskModel
  factory TaskModel.fromJson(Map<String, dynamic> json) => TaskModel(
    id: json['id'] as int?, title: json['title'] as String, description: json['description'] as String?,
    priority: json['priority'] as String, dueDate: json['due_date'] as String?,
    isCompleted: (json['is_completed'] as int) == 1, categoryId: json['category_id'] as int?,
    createdAt: json['created_at'] as String,
  );

  // Convert back to a Map for database writes
  Map<String, dynamic> toJson() => {
    if (id != null) 'id': id, 'title': title, 'description': description,
    'priority': priority, 'due_date': dueDate, 'is_completed': isCompleted ? 1 : 0,
    'category_id': categoryId, 'created_at': createdAt,
  };

  // Bridge between domain entity and data model
  factory TaskModel.fromEntity(TaskEntity e) => TaskModel(
    id: e.id, title: e.title, description: e.description, priority: e.priority,
    dueDate: e.dueDate, isCompleted: e.isCompleted, categoryId: e.categoryId, createdAt: e.createdAt,
  );

  TaskEntity toEntity() => TaskEntity(
    id: id, title: title, description: description, priority: priority,
    dueDate: dueDate, isCompleted: isCompleted, categoryId: categoryId, createdAt: createdAt,
  );
}

Why does isCompleted convert between bool and int?

SQLite has no native boolean type. It stores true/false as 1/0 integers. The fromJson factory converts the integer back to a Dart bool, and toJson converts it back to an integer for storage.

Notice that due_date and category_id use snake_case (matching the database columns), while the Dart fields use camelCase. The model handles this translation automatically.

  • Save task_model.dart. Your IDE should show no red underlines in either model file.

Seeing import errors?

Make sure your model files are in lib/data/models/. The relative import paths (../../domain/entities/) rely on this exact folder depth.

help me fix import path errors in my data models

✔️ Awesome, I've got everything!

Great. Double check you have saved both model files before moving on.

ⓧ I'd like to double check the full code

Here is the full category_model.dart:

import '../../domain/entities/category_entity.dart';

class CategoryModel {
  final int? id;
  final String name;
  final String color;
  const CategoryModel({this.id, required this.name, required this.color});

  factory CategoryModel.fromJson(Map<String, dynamic> json) =>
      CategoryModel(id: json['id'] as int?, name: json['name'] as String, color: json['color'] as String);

  Map<String, dynamic> toJson() => {if (id != null) 'id': id, 'name': name, 'color': color};

  factory CategoryModel.fromEntity(CategoryEntity e) => CategoryModel(id: e.id, name: e.name, color: e.color);
  CategoryEntity toEntity() => CategoryEntity(id: id, name: name, color: color);
}

And here is the full task_model.dart:

import '../../domain/entities/task_entity.dart';

class TaskModel {
  final int? id;
  final String title;
  final String? description;
  final String priority;
  final String? dueDate;
  final bool isCompleted;
  final int? categoryId;
  final String createdAt;

  const TaskModel({this.id, required this.title, this.description, required this.priority, this.dueDate, required this.isCompleted, this.categoryId, required this.createdAt});

  factory TaskModel.fromJson(Map<String, dynamic> json) => TaskModel(
    id: json['id'] as int?, title: json['title'] as String, description: json['description'] as String?,
    priority: json['priority'] as String, dueDate: json['due_date'] as String?,
    isCompleted: (json['is_completed'] as int) == 1, categoryId: json['category_id'] as int?,
    createdAt: json['created_at'] as String,
  );

  Map<String, dynamic> toJson() => {
    if (id != null) 'id': id, 'title': title, 'description': description,
    'priority': priority, 'due_date': dueDate, 'is_completed': isCompleted ? 1 : 0,
    'category_id': categoryId, 'created_at': createdAt,
  };

  factory TaskModel.fromEntity(TaskEntity e) => TaskModel(
    id: e.id, title: e.title, description: e.description, priority: e.priority,
    dueDate: e.dueDate, isCompleted: e.isCompleted, categoryId: e.categoryId, createdAt: e.createdAt,
  );

  TaskEntity toEntity() => TaskEntity(
    id: id, title: title, description: description, priority: priority,
    dueDate: dueDate, isCompleted: isCompleted, categoryId: categoryId, createdAt: createdAt,
  );
}
Build the concrete repository implementations

Your domain layer defined abstract repository interfaces. Now you will write the concrete classes that fulfill those contracts by coordinating between your data sources.

What is the Repository Pattern?

The Repository Pattern hides data-source details behind a clean interface. Your use cases call repository methods without knowing whether data comes from SQLite, Hive, or a remote API.

The concrete implementation decides the strategy. For tasks, you will build a cache-first approach: read from Hive first (fast), fall back to SQLite on a miss (reliable), and write through to both on every mutation.

  • Create the file lib/data/repositories/category_repository_impl.dart and add this code:
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../../domain/entities/category_entity.dart';
import '../../domain/repositories/category_repository.dart';
import '../datasources/local/database_helper.dart';
import '../models/category_model.dart';

class CategoryRepositoryImpl implements CategoryRepository {
  final DatabaseHelper _dbHelper;
  const CategoryRepositoryImpl(this._dbHelper);

  @override
  Future<Either<Failure, List<CategoryEntity>>> getCategories() async {
    try {
      // Query all rows, convert each Map to a model, then to a domain entity
      final rows = await _dbHelper.queryAllCategories();
      return Right(rows.map((r) => CategoryModel.fromJson(r).toEntity()).toList());
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, int>> addCategory(CategoryEntity category) async {
    try {
      // Convert entity to model, then to a Map for database insertion
      final id = await _dbHelper.insertCategory(CategoryModel.fromEntity(category).toJson());
      return Right(id);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, void>> deleteCategory(int id) async {
    try { await _dbHelper.deleteCategory(id); return const Right(null); }
    catch (e) { return Left(DatabaseFailure(e.toString())); }
  }
}

What does this code do?

  • CategoryRepositoryImpl implements the abstract CategoryRepository contract from the domain layer.
  • Every method wraps its work in a try/catch. On success, it returns Right(value). On failure, it returns Left(DatabaseFailure(...)). This is the Either pattern from dartz.
  • The model acts as a translator: database Map goes in via fromJson, domain entity comes out via toEntity().
  • Save category_repository_impl.dart.
  • You will not see a visible change yet. The next file (TaskRepositoryImpl) follows the same pattern but adds the caching strategy.
  • Create lib/data/repositories/task_repository_impl.dart and add this code:
import 'package:dartz/dartz.dart';
import '../../core/error/exceptions.dart';
import '../../core/error/failures.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/repositories/task_repository.dart';
import '../datasources/local/database_helper.dart';
import '../datasources/local/hive_cache_datasource.dart';
import '../models/task_model.dart';

class TaskRepositoryImpl implements TaskRepository {
  final DatabaseHelper _dbHelper;
  final HiveCacheDataSource _cache;
  const TaskRepositoryImpl(this._dbHelper, this._cache);

  @override
  Future<Either<Failure, List<TaskEntity>>> getTasks({int? categoryId, bool? isCompleted, String? priority}) async {
    try {
      // Cache-first: try Hive first for fast reads
      final cached = _cache.getCachedTasks();
      if (cached.isNotEmpty) {
        return Right(cached.map((c) => TaskModel(id: c.id, title: c.title, description: c.description, priority: c.priority, dueDate: c.dueDate, isCompleted: c.isCompleted, categoryId: c.categoryId, createdAt: c.createdAt).toEntity()).toList());
      }
      // Cache miss: fall back to SQLite
      final rows = await _dbHelper.queryTasksByFilter(categoryId: categoryId, isCompleted: isCompleted, priority: priority);
      final models = rows.map(TaskModel.fromJson).toList();
      // Write through: update cache with fresh data from database
      await _cache.cacheTasks(models.map((m) => CachedTask(id: m.id!, title: m.title, description: m.description, priority: m.priority, dueDate: m.dueDate, isCompleted: m.isCompleted, categoryId: m.categoryId, createdAt: m.createdAt)).toList());
      return Right(models.map((m) => m.toEntity()).toList());
    } on CacheException catch (e) { return Left(CacheFailure(e.message)); }
    catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, int>> addTask(TaskEntity task) async {
    try {
      final model = TaskModel.fromEntity(task);
      // Write to SQLite first (source of truth)
      final id = await _dbHelper.insertTask(model.toJson());
      // Then update cache for fast subsequent reads
      await _cache.cacheTask(CachedTask(id: id, title: model.title, description: model.description, priority: model.priority, dueDate: model.dueDate, isCompleted: model.isCompleted, categoryId: model.categoryId, createdAt: model.createdAt));
      return Right(id);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, void>> updateTask(TaskEntity task) async {
    try {
      final model = TaskModel.fromEntity(task);
      // Write through to both sources
      await _dbHelper.updateTask(model.toJson());
      await _cache.cacheTask(CachedTask(id: task.id!, title: model.title, description: model.description, priority: model.priority, dueDate: model.dueDate, isCompleted: model.isCompleted, categoryId: model.categoryId, createdAt: model.createdAt));
      return const Right(null);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, void>> deleteTask(int id) async {
    try {
      // Remove from both sources
      await _dbHelper.deleteTask(id);
      await _cache.deleteCachedTask(id);
      return const Right(null);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }
}

How does the cache-first strategy work?

  • On reads (getTasks): check Hive first. If the cache has data, return it immediately without hitting SQLite. If the cache is empty, query SQLite, then populate the cache for next time.
  • On writes (addTask, updateTask, deleteTask): always write to SQLite first (the durable source of truth), then update the Hive cache to keep it in sync.
  • The catch blocks distinguish between CacheException (wrapped in CacheFailure) and generic exceptions (wrapped in DatabaseFailure). This lets the UI display targeted error messages.
  • Save task_repository_impl.dart.
  • Confirm your IDE shows no red underlines in either repository file. The imports should resolve to existing files in your project.

Seeing errors on HiveCacheDataSource or CachedTask?

Make sure your hive_cache_datasource.dart file exports the CachedTask class and the methods getCachedTasks(), cacheTasks(), cacheTask(), and deleteCachedTask(). These were created in the earlier data sources step.

help me resolve HiveCacheDataSource import issues in my repository

✔️ Awesome, I've got everything!

Great. Make sure both repository files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full category_repository_impl.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../../domain/entities/category_entity.dart';
import '../../domain/repositories/category_repository.dart';
import '../datasources/local/database_helper.dart';
import '../models/category_model.dart';

class CategoryRepositoryImpl implements CategoryRepository {
  final DatabaseHelper _dbHelper;
  const CategoryRepositoryImpl(this._dbHelper);

  @override
  Future<Either<Failure, List<CategoryEntity>>> getCategories() async {
    try {
      final rows = await _dbHelper.queryAllCategories();
      return Right(rows.map((r) => CategoryModel.fromJson(r).toEntity()).toList());
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, int>> addCategory(CategoryEntity category) async {
    try {
      final id = await _dbHelper.insertCategory(CategoryModel.fromEntity(category).toJson());
      return Right(id);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, void>> deleteCategory(int id) async {
    try { await _dbHelper.deleteCategory(id); return const Right(null); }
    catch (e) { return Left(DatabaseFailure(e.toString())); }
  }
}

And here is the full task_repository_impl.dart:

import 'package:dartz/dartz.dart';
import '../../core/error/exceptions.dart';
import '../../core/error/failures.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/repositories/task_repository.dart';
import '../datasources/local/database_helper.dart';
import '../datasources/local/hive_cache_datasource.dart';
import '../models/task_model.dart';

class TaskRepositoryImpl implements TaskRepository {
  final DatabaseHelper _dbHelper;
  final HiveCacheDataSource _cache;
  const TaskRepositoryImpl(this._dbHelper, this._cache);

  @override
  Future<Either<Failure, List<TaskEntity>>> getTasks({int? categoryId, bool? isCompleted, String? priority}) async {
    try {
      final cached = _cache.getCachedTasks();
      if (cached.isNotEmpty) {
        return Right(cached.map((c) => TaskModel(id: c.id, title: c.title, description: c.description, priority: c.priority, dueDate: c.dueDate, isCompleted: c.isCompleted, categoryId: c.categoryId, createdAt: c.createdAt).toEntity()).toList());
      }
      final rows = await _dbHelper.queryTasksByFilter(categoryId: categoryId, isCompleted: isCompleted, priority: priority);
      final models = rows.map(TaskModel.fromJson).toList();
      await _cache.cacheTasks(models.map((m) => CachedTask(id: m.id!, title: m.title, description: m.description, priority: m.priority, dueDate: m.dueDate, isCompleted: m.isCompleted, categoryId: m.categoryId, createdAt: m.createdAt)).toList());
      return Right(models.map((m) => m.toEntity()).toList());
    } on CacheException catch (e) { return Left(CacheFailure(e.message)); }
    catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, int>> addTask(TaskEntity task) async {
    try {
      final model = TaskModel.fromEntity(task);
      final id = await _dbHelper.insertTask(model.toJson());
      await _cache.cacheTask(CachedTask(id: id, title: model.title, description: model.description, priority: model.priority, dueDate: model.dueDate, isCompleted: model.isCompleted, categoryId: model.categoryId, createdAt: model.createdAt));
      return Right(id);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, void>> updateTask(TaskEntity task) async {
    try {
      final model = TaskModel.fromEntity(task);
      await _dbHelper.updateTask(model.toJson());
      await _cache.cacheTask(CachedTask(id: task.id!, title: model.title, description: model.description, priority: model.priority, dueDate: model.dueDate, isCompleted: model.isCompleted, categoryId: model.categoryId, createdAt: model.createdAt));
      return const Right(null);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }

  @override
  Future<Either<Failure, void>> deleteTask(int id) async {
    try {
      await _dbHelper.deleteTask(id);
      await _cache.deleteCachedTask(id);
      return const Right(null);
    } catch (e) { return Left(DatabaseFailure(e.toString())); }
  }
}
Update TaskBloc to use domain use cases

Right now TaskBloc exists but does not connect to the repository through use cases. You will refactor it so that every event handler delegates to the appropriate use case, keeping the BLoC thin and testable.

  • Open lib/presentation/bloc/task/task_bloc.dart and replace its entire contents with the following:
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/update_task.dart';
import '../../../domain/usecases/delete_task.dart';

// --- Events ---
abstract class TaskEvent extends Equatable {
  const TaskEvent();
  @override List<Object?> get props => [];
}

class LoadTasks extends TaskEvent {
  final int? categoryId; final bool? isCompleted; final String? priority;
  const LoadTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

class AddTaskEvent extends TaskEvent {
  final TaskEntity task;
  const AddTaskEvent(this.task);
  @override List<Object?> get props => [task];
}

class UpdateTaskEvent extends TaskEvent {
  final TaskEntity task;
  const UpdateTaskEvent(this.task);
  @override List<Object?> get props => [task];
}

class DeleteTaskEvent extends TaskEvent {
  final int id;
  const DeleteTaskEvent(this.id);
  @override List<Object?> get props => [id];
}

class FilterTasks extends TaskEvent {
  final int? categoryId; final bool? isCompleted; final String? priority;
  const FilterTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

// --- States ---
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(this.tasks);
  @override List<Object?> get props => [tasks];
}

class TaskError extends TaskState {
  final String message;
  const TaskError(this.message);
  @override List<Object?> get props => [message];
}

// --- BLoC ---
class TaskBloc extends Bloc<TaskEvent, TaskState> {
  final GetTasks _getTasks;
  final AddTask _addTask;
  final UpdateTask _updateTask;
  final DeleteTask _deleteTask;

  // Accept use cases via constructor for dependency injection
  TaskBloc({required GetTasks getTasks, required AddTask addTask, required UpdateTask updateTask, required DeleteTask deleteTask})
      : _getTasks = getTasks, _addTask = addTask, _updateTask = updateTask, _deleteTask = deleteTask,
        super(TaskInitial()) {
    on<LoadTasks>(_onLoadTasks);
    on<AddTaskEvent>(_onAddTask);
    on<UpdateTaskEvent>(_onUpdateTask);
    on<DeleteTaskEvent>(_onDeleteTask);
    on<FilterTasks>(_onFilterTasks);
  }

  Future<void> _onLoadTasks(LoadTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    final result = await _getTasks(GetTasksParams(categoryId: event.categoryId, isCompleted: event.isCompleted, priority: event.priority));
    result.fold((f) => emit(TaskError(f.message)), (tasks) => emit(TaskLoaded(tasks)));
  }

  Future<void> _onAddTask(AddTaskEvent event, Emitter<TaskState> emit) async {
    final result = await _addTask(event.task);
    result.fold((f) => emit(TaskError(f.message)), (_) => add(const LoadTasks()));
  }

  Future<void> _onUpdateTask(UpdateTaskEvent event, Emitter<TaskState> emit) async {
    final result = await _updateTask(event.task);
    result.fold((f) => emit(TaskError(f.message)), (_) => add(const LoadTasks()));
  }

  Future<void> _onDeleteTask(DeleteTaskEvent event, Emitter<TaskState> emit) async {
    final result = await _deleteTask(event.id);
    result.fold((f) => emit(TaskError(f.message)), (_) => add(const LoadTasks()));
  }

  Future<void> _onFilterTasks(FilterTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    final result = await _getTasks(GetTasksParams(categoryId: event.categoryId, isCompleted: event.isCompleted, priority: event.priority));
    result.fold((f) => emit(TaskError(f.message)), (tasks) => emit(TaskLoaded(tasks)));
  }
}

What changed in TaskBloc?

  • The constructor now accepts four use cases (GetTasks, AddTask, UpdateTask, DeleteTask) as named parameters. This makes the BLoC easy to test with mocks.
  • Each event handler calls its use case and uses result.fold() to emit either a TaskError (on Left/failure) or the success state (on Right).
  • After add, update, or delete succeeds, the handler dispatches a fresh LoadTasks() event to refresh the list automatically.
  • Save task_bloc.dart.
  • Verify the entire project compiles by running this in your terminal:
flutter analyze
  • You should see No issues found! in the output. This confirms all imports resolve and types match across layers.

Seeing analysis errors?

If the analyzer reports missing methods on DatabaseHelper (like queryTasksByFilter or insertTask), confirm your DatabaseHelper class from the earlier step exposes these methods with the expected signatures.

If you see type mismatches on the use case imports, double check that your use case files in lib/domain/usecases/ export the class names exactly as GetTasks, AddTask, UpdateTask, and DeleteTask.

help me fix flutter analyze errors in my data layer

✔️ Awesome, I've got everything!

Great. Your data layer is complete and your BLoC is wired to use cases.

ⓧ I'd like to double check the full code

Here is the full task_bloc.dart:

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/update_task.dart';
import '../../../domain/usecases/delete_task.dart';

abstract class TaskEvent extends Equatable {
  const TaskEvent();
  @override List<Object?> get props => [];
}

class LoadTasks extends TaskEvent {
  final int? categoryId; final bool? isCompleted; final String? priority;
  const LoadTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

class AddTaskEvent extends TaskEvent {
  final TaskEntity task;
  const AddTaskEvent(this.task);
  @override List<Object?> get props => [task];
}

class UpdateTaskEvent extends TaskEvent {
  final TaskEntity task;
  const UpdateTaskEvent(this.task);
  @override List<Object?> get props => [task];
}

class DeleteTaskEvent extends TaskEvent {
  final int id;
  const DeleteTaskEvent(this.id);
  @override List<Object?> get props => [id];
}

class FilterTasks extends TaskEvent {
  final int? categoryId; final bool? isCompleted; final String? priority;
  const FilterTasks({this.categoryId, this.isCompleted, this.priority});
  @override List<Object?> get props => [categoryId, isCompleted, priority];
}

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(this.tasks);
  @override List<Object?> get props => [tasks];
}

class TaskError extends TaskState {
  final String message;
  const TaskError(this.message);
  @override List<Object?> get props => [message];
}

class TaskBloc extends Bloc<TaskEvent, TaskState> {
  final GetTasks _getTasks;
  final AddTask _addTask;
  final UpdateTask _updateTask;
  final DeleteTask _deleteTask;

  TaskBloc({required GetTasks getTasks, required AddTask addTask, required UpdateTask updateTask, required DeleteTask deleteTask})
      : _getTasks = getTasks, _addTask = addTask, _updateTask = updateTask, _deleteTask = deleteTask,
        super(TaskInitial()) {
    on<LoadTasks>(_onLoadTasks);
    on<AddTaskEvent>(_onAddTask);
    on<UpdateTaskEvent>(_onUpdateTask);
    on<DeleteTaskEvent>(_onDeleteTask);
    on<FilterTasks>(_onFilterTasks);
  }

  Future<void> _onLoadTasks(LoadTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    final result = await _getTasks(GetTasksParams(categoryId: event.categoryId, isCompleted: event.isCompleted, priority: event.priority));
    result.fold((f) => emit(TaskError(f.message)), (tasks) => emit(TaskLoaded(tasks)));
  }

  Future<void> _onAddTask(AddTaskEvent event, Emitter<TaskState> emit) async {
    final result = await _addTask(event.task);
    result.fold((f) => emit(TaskError(f.message)), (_) => add(const LoadTasks()));
  }

  Future<void> _onUpdateTask(UpdateTaskEvent event, Emitter<TaskState> emit) async {
    final result = await _updateTask(event.task);
    result.fold((f) => emit(TaskError(f.message)), (_) => add(const LoadTasks()));
  }

  Future<void> _onDeleteTask(DeleteTaskEvent event, Emitter<TaskState> emit) async {
    final result = await _deleteTask(event.id);
    result.fold((f) => emit(TaskError(f.message)), (_) => add(const LoadTasks()));
  }

  Future<void> _onFilterTasks(FilterTasks event, Emitter<TaskState> emit) async {
    emit(TaskLoading());
    final result = await _getTasks(GetTasksParams(categoryId: event.categoryId, isCompleted: event.isCompleted, priority: event.priority));
    result.fold((f) => emit(TaskError(f.message)), (tasks) => emit(TaskLoaded(tasks)));
  }
}

Your data layer is fully connected. Models translate between database rows and domain entities. Repositories coordinate your two local data sources behind a clean interface. Next up, you will wire everything together with dependency injection so the app can run end-to-end.

Wire Up Dependency Injection with get_it

You have all three layers of Clean Architecture built: entities and use cases in the domain layer, models and repositories in the data layer, and BLoCs in the presentation layer. But the app does not compile yet because nothing connects these layers together.

A service locator solves this problem by acting as a central registry where every dependency is registered once at startup and retrieved anywhere in the app. In this step, you will set up get_it as the service locator, register every dependency in the correct order, and wire the whole graph into your main.dart so the app compiles and runs with real data flowing through all layers.

In this step, get ready to:
  • Create an injection container file that registers all dependencies.
  • Register data sources, repositories, use cases, and BLoCs in the correct order.
  • Wire get_it into the app so all layers connect and real data flows end-to-end.
Create the injection container with data source registrations

The injection container is a single file that defines your entire dependency graph. Registration order matters because each layer depends on the one below it. Data sources must be registered first because repositories depend on them.

  • Create a new file called lib/injection_container.dart and add the following code:
import 'package:get_it/get_it.dart';
import 'package:hive_ce/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'data/datasources/local/app_settings_datasource.dart';
import 'data/datasources/local/database_helper.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'data/repositories/category_repository_impl.dart';
import 'data/repositories/task_repository_impl.dart';
import 'domain/repositories/category_repository.dart';
import 'domain/repositories/task_repository.dart';
import 'domain/usecases/add_task.dart';
import 'domain/usecases/delete_task.dart';
import 'domain/usecases/get_categories.dart';
import 'domain/usecases/get_tasks.dart';
import 'domain/usecases/update_task.dart';
import 'presentation/bloc/task/task_bloc.dart';

// Global service locator instance accessible throughout the app
final sl = GetIt.instance;

Future<void> initDependencies() async {
  // Data sources - registered as singletons (one shared instance)
  sl.registerSingleton<DatabaseHelper>(DatabaseHelper());
  sl.registerSingleton<HiveCacheDataSource>(
    HiveCacheDataSource(Hive.box<CachedTask>('tasks_cache')),
  );
  final prefs = await SharedPreferencesWithCache.create(
    cacheOptions: const SharedPreferencesWithCacheOptions(),
  );
  sl.registerSingleton<AppSettingsDataSource>(AppSettingsDataSource(prefs));
}

What does this code do?

The sl variable is a global reference to the get_it service locator. Any file that imports this module can call sl<SomeType>() to retrieve a registered dependency.

Data sources are registered with registerSingleton because they hold stateful resources (a database connection, an open Hive box, a preferences cache). You want exactly one instance shared across the entire app.

Why singletons for data sources?

Opening multiple database connections or Hive boxes wastes memory and can cause locking conflicts. Singletons guarantee a single connection is reused everywhere.

Register repositories, use cases, and BLoCs

Now you will register the remaining layers. Repositories depend on data sources, use cases depend on repositories, and BLoCs depend on use cases.

  • In lib/injection_container.dart, find the closing } of the initDependencies function.
  • Add the following code just above that closing brace:
  // Repositories - registered as lazy singletons (created on first use)
  sl.registerLazySingleton<TaskRepository>(
    () => TaskRepositoryImpl(sl<DatabaseHelper>(), sl<HiveCacheDataSource>()),
  );
  sl.registerLazySingleton<CategoryRepository>(
    () => CategoryRepositoryImpl(sl<DatabaseHelper>()),
  );

  // Use cases - registered as lazy singletons (one instance, created when needed)
  sl.registerLazySingleton(() => GetTasks(sl<TaskRepository>()));
  sl.registerLazySingleton(() => AddTask(sl<TaskRepository>()));
  sl.registerLazySingleton(() => UpdateTask(sl<TaskRepository>()));
  sl.registerLazySingleton(() => DeleteTask(sl<TaskRepository>()));
  sl.registerLazySingleton(() => GetCategories(sl<CategoryRepository>()));

  // BLoCs - registered as factories (new instance each time)
  sl.registerFactory(() => TaskBloc(
    getTasks: sl<GetTasks>(),
    addTask: sl<AddTask>(),
    updateTask: sl<UpdateTask>(),
    deleteTask: sl<DeleteTask>(),
  ));

Why different registration types for each layer?

  • registerLazySingleton creates the instance on first access, then reuses it. Repositories and use cases are stateless coordinators that only need one instance.
  • registerFactory creates a new instance every time. BLoCs hold state (current task list, loading flags), so each screen that needs a TaskBloc gets a fresh one to avoid stale state leaking between screens.
  • Save lib/injection_container.dart.

Seeing import errors?

Make sure every import path matches your folder structure exactly. The CachedTask class is imported indirectly through hive_cache_datasource.dart, so if that file does not export it, add export or check the class name matches.

help me fix import errors in my injection_container.dart

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/injection_container.dart before moving on.

ⓧ I'd like to double check the full code

import 'package:get_it/get_it.dart';
import 'package:hive_ce/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'data/datasources/local/app_settings_datasource.dart';
import 'data/datasources/local/database_helper.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'data/repositories/category_repository_impl.dart';
import 'data/repositories/task_repository_impl.dart';
import 'domain/repositories/category_repository.dart';
import 'domain/repositories/task_repository.dart';
import 'domain/usecases/add_task.dart';
import 'domain/usecases/delete_task.dart';
import 'domain/usecases/get_categories.dart';
import 'domain/usecases/get_tasks.dart';
import 'domain/usecases/update_task.dart';
import 'presentation/bloc/task/task_bloc.dart';

final sl = GetIt.instance;

Future<void> initDependencies() async {
  // Data sources
  sl.registerSingleton<DatabaseHelper>(DatabaseHelper());
  sl.registerSingleton<HiveCacheDataSource>(
    HiveCacheDataSource(Hive.box<CachedTask>('tasks_cache')),
  );
  final prefs = await SharedPreferencesWithCache.create(
    cacheOptions: const SharedPreferencesWithCacheOptions(),
  );
  sl.registerSingleton<AppSettingsDataSource>(AppSettingsDataSource(prefs));

  // Repositories
  sl.registerLazySingleton<TaskRepository>(
    () => TaskRepositoryImpl(sl<DatabaseHelper>(), sl<HiveCacheDataSource>()),
  );
  sl.registerLazySingleton<CategoryRepository>(
    () => CategoryRepositoryImpl(sl<DatabaseHelper>()),
  );

  // Use cases
  sl.registerLazySingleton(() => GetTasks(sl<TaskRepository>()));
  sl.registerLazySingleton(() => AddTask(sl<TaskRepository>()));
  sl.registerLazySingleton(() => UpdateTask(sl<TaskRepository>()));
  sl.registerLazySingleton(() => DeleteTask(sl<TaskRepository>()));
  sl.registerLazySingleton(() => GetCategories(sl<CategoryRepository>()));

  // BLoCs
  sl.registerFactory(() => TaskBloc(
    getTasks: sl<GetTasks>(),
    addTask: sl<AddTask>(),
    updateTask: sl<UpdateTask>(),
    deleteTask: sl<DeleteTask>(),
  ));
}
Wire get_it into main.dart

The injection container is ready, but nothing calls it yet. You need to initialize Hive, open the required boxes, call initDependencies(), and then pass the service-locator-created BLoC into your widget tree via MultiBlocProvider.

  • Replace the entire contents of lib/main.dart with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'injection_container.dart';
import 'presentation/bloc/task/task_bloc.dart';
import 'presentation/cubits/filter/filter_cubit.dart';
import 'presentation/cubits/theme/theme_cubit.dart';
import 'presentation/screens/task_list_screen.dart';

void main() async {
  // Ensure Flutter bindings are initialized before async work
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Hive for local caching
  await Hive.initFlutter();
  Hive.registerAdapter(CachedTaskAdapter());
  await Hive.openBox<CachedTask>('tasks_cache');
  await Hive.openBox('app_cache');

  // Register all dependencies in get_it
  await initDependencies();

  // Run the app with all BLoCs provided at the top of the tree
  runApp(
    MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => ThemeCubit()),
        BlocProvider(create: (_) => FilterCubit()),
        BlocProvider(create: (_) => sl<TaskBloc>()..add(const LoadTasks())),
      ],
      child: const TaskManagerApp(),
    ),
  );
}

class TaskManagerApp extends StatelessWidget {
  const TaskManagerApp({super.key});
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ThemeCubit, ThemeState>(
      builder: (context, themeState) {
        return MaterialApp(
          title: 'Task Manager',
          themeMode: themeState.themeMode,
          theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light), useMaterial3: true),
          darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.dark), useMaterial3: true),
          home: const TaskListScreen(),
        );
      },
    );
  }
}

What does this code do?

  • WidgetsFlutterBinding.ensureInitialized() is required before any async work in main() because Flutter's engine must be ready before you access platform channels (file system, shared preferences).
  • Hive.initFlutter() sets the storage path for Hive boxes. Opening the tasks_cache box here means the injection container can access it synchronously when registering HiveCacheDataSource.
  • sl<TaskBloc>()..add(const LoadTasks()) retrieves a fresh TaskBloc from get_it and immediately dispatches a LoadTasks event so tasks load as soon as the app starts.
  • Save lib/main.dart.
  • Run the app on your emulator or device. You should see the Task List screen load without any compile errors.

App crashes on startup?

If you see a HiveError about the box not being open, make sure Hive.openBox<CachedTask>('tasks_cache') is called before initDependencies(). The injection container assumes the box is already open when it registers HiveCacheDataSource.

If you see a type-not-registered error, check that you registered dependencies in the correct order: data sources first, then repositories, then use cases, then BLoCs.

help me debug a get_it registration error in my Flutter app startup

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/main.dart and that the app is running on your device or emulator.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'data/datasources/local/hive_cache_datasource.dart';
import 'injection_container.dart';
import 'presentation/bloc/task/task_bloc.dart';
import 'presentation/cubits/filter/filter_cubit.dart';
import 'presentation/cubits/theme/theme_cubit.dart';
import 'presentation/screens/task_list_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(CachedTaskAdapter());
  await Hive.openBox<CachedTask>('tasks_cache');
  await Hive.openBox('app_cache');
  await initDependencies();
  runApp(
    MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => ThemeCubit()),
        BlocProvider(create: (_) => FilterCubit()),
        BlocProvider(create: (_) => sl<TaskBloc>()..add(const LoadTasks())),
      ],
      child: const TaskManagerApp(),
    ),
  );
}

class TaskManagerApp extends StatelessWidget {
  const TaskManagerApp({super.key});
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ThemeCubit, ThemeState>(
      builder: (context, themeState) {
        return MaterialApp(
          title: 'Task Manager',
          themeMode: themeState.themeMode,
          theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light), useMaterial3: true),
          darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.dark), useMaterial3: true),
          home: const TaskListScreen(),
        );
      },
    );
  }
}

Your dependency graph is fully wired. Every layer of Clean Architecture is now connected through get_it, and the app compiles and runs with real SQLite data flowing through the cache, into the repository, through use cases, and into the BLoC that drives your UI. Next up, you will build the complete task flow features that let users create, view, edit, and delete tasks.

Build the Complete Task Flow and Screens

Your architecture layers are wired, the dependency injection graph is complete, and the data layer persists tasks to SQLite with Hive caching. The foundation is solid. Now it's time to build the screens that let users actually create, view, edit, and delete tasks.

In this step, you'll build three feature screens that complete the task management workflow. Each screen demonstrates how the presentation layer communicates with the domain layer through BLoC events and states. You'll dispatch events to add, update, and delete tasks, and the UI will react to state changes automatically.

In this step, get ready to:
  • Build an Add Task screen with a form that dispatches events to TaskBloc.
  • Build a Category Management screen with add and delete capabilities.
  • Build a Task Detail screen with edit form, confirmation dialog, and delete functionality.
Build the Add Task screen

The Add Task screen is the entry point for creating new tasks. It collects a title, optional description, priority level, and due date through a form. When the user taps save, the screen constructs a TaskEntity and dispatches an AddTaskEvent to the TaskBloc.

  • Create a new file at lib/presentation/screens/add_task_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/task_entity.dart';
import '../bloc/task/task_bloc.dart';

class AddTaskScreen extends StatefulWidget {
  const AddTaskScreen({super.key});
  @override
  State<AddTaskScreen> createState() => _AddTaskScreenState();
}

class _AddTaskScreenState extends State<AddTaskScreen> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _descController = TextEditingController();
  String _priority = 'Medium';
  DateTime? _dueDate;

  @override
  void dispose() {
    _titleController.dispose();
    _descController.dispose();
    super.dispose();
  }

  void _save() {
    if (_formKey.currentState!.validate()) {
      final task = TaskEntity(
        title: _titleController.text.trim(),
        description: _descController.text.trim().isEmpty ? null : _descController.text.trim(),
        priority: _priority,
        dueDate: _dueDate?.toIso8601String(),
        isCompleted: false,
        createdAt: DateTime.now().toIso8601String(),
      );
      context.read<TaskBloc>().add(AddTaskEvent(task));
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Add Task')),
      body: Form(
        key: _formKey,
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            TextFormField(
              controller: _titleController,
              decoration: const InputDecoration(labelText: 'Title'),
              validator: (v) => v == null || v.trim().isEmpty ? 'Title is required' : null,
            ),
            const SizedBox(height: 12),
            TextFormField(
              controller: _descController,
              decoration: const InputDecoration(labelText: 'Description'),
              maxLines: 3,
            ),
            const SizedBox(height: 12),
            DropdownButtonFormField<String>(
              value: _priority,
              decoration: const InputDecoration(labelText: 'Priority'),
              items: ['Low', 'Medium', 'High'].map((p) => DropdownMenuItem(value: p, child: Text(p))).toList(),
              onChanged: (v) => setState(() => _priority = v!),
            ),
            const SizedBox(height: 12),
            ListTile(
              title: Text(_dueDate == null ? 'No due date' : 'Due: ${_dueDate!.toLocal().toString().split(' ')[0]}'),
              trailing: const Icon(Icons.calendar_today),
              onTap: () async {
                final picked = await showDatePicker(
                  context: context,
                  initialDate: DateTime.now(),
                  firstDate: DateTime.now(),
                  lastDate: DateTime(2100),
                );
                if (picked != null) setState(() => _dueDate = picked);
              },
            ),
            const SizedBox(height: 24),
            ElevatedButton(onPressed: _save, child: const Text('Save Task')),
          ],
        ),
      ),
    );
  }
}

What does this code do?

  • A GlobalKey<FormState> enables form validation. The _save() method only proceeds if all validators pass.
  • The DropdownButtonFormField gives users a priority picker with Low, Medium, and High options.
  • The showDatePicker Flutter widget handles date selection natively. The result is stored as an ISO 8601 string.
  • On save, a TaskEntity is constructed and dispatched via context.read<TaskBloc>().add(AddTaskEvent(task)). Then the screen pops back to the task list.
  • Save add_task_screen.dart.
  • Run the app with flutter run. Once the task list loads, tap the floating action button. You should see the Add Task form with title, description, priority dropdown, date picker, and a Save Task button.

Screen not opening from the FAB?

Make sure your TaskListScreen's FAB navigates to AddTaskScreen. The onPressed callback should push a MaterialPageRoute to AddTaskScreen(). Also confirm you imported the new screen file at the top of your TaskListScreen.

Help me debug my FAB navigation to AddTaskScreen

Build the Category Management screen

Category management lets users organize tasks by grouping them. This screen loads existing categories from the GetCategories use case, displays them in a list, and provides a text field to add new ones. It interacts directly with the CategoryRepository through the service locator.

  • Create a new file at lib/presentation/screens/category_management_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/category_entity.dart';
import '../../domain/usecases/get_categories.dart';
import '../../domain/usecases/add_task.dart';
import '../../injection_container.dart';
import '../../data/repositories/category_repository_impl.dart';
import '../../domain/repositories/category_repository.dart';

class CategoryManagementScreen extends StatefulWidget {
  const CategoryManagementScreen({super.key});
  @override
  State<CategoryManagementScreen> createState() => _CategoryManagementScreenState();
}

class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
  List<CategoryEntity> _categories = [];
  final _nameController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _loadCategories();
  }

  Future<void> _loadCategories() async {
    final result = await sl<GetCategories>()();
    result.fold((_) {}, (cats) => setState(() => _categories = cats));
  }

  Future<void> _addCategory() async {
    if (_nameController.text.trim().isEmpty) return;
    final repo = sl<CategoryRepository>();
    await repo.addCategory(CategoryEntity(name: _nameController.text.trim(), color: '#6750A4'));
    _nameController.clear();
    await _loadCategories();
  }

  Future<void> _deleteCategory(int id) async {
    final repo = sl<CategoryRepository>();
    await repo.deleteCategory(id);
    await _loadCategories();
  }

  @override
  void dispose() { _nameController.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Categories')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(child: TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'Category name'))),
                IconButton(icon: const Icon(Icons.add), onPressed: _addCategory),
              ],
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: _categories.length,
              itemBuilder: (context, index) {
                final cat = _categories[index];
                return ListTile(
                  title: Text(cat.name),
                  trailing: IconButton(
                    icon: const Icon(Icons.delete),
                    onPressed: () => _deleteCategory(cat.id!),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

What does this code do?

  • The screen uses sl<GetCategories>()() to load categories via the service locator. The double parentheses invoke the use case's call method.
  • The result.fold handles the Either return type. On success, it updates the local list. On failure, it does nothing (keeping the list empty).
  • Adding a category goes directly through sl<CategoryRepository>() and refreshes the list. The default color is the app's primary purple.
  • Delete uses the category's id to remove it from the database, then reloads the list.
  • Save category_management_screen.dart.
  • Hot restart the app. Navigate to the Category Management screen from the app bar. You should see an empty list with a text field and an add button at the top.
  • Type a category name and tap the add icon. The category should appear in the list below.

Categories not loading?

Make sure your CategoryRepository interface defines both addCategory and deleteCategory methods, and that CategoryRepositoryImpl implements them. Also confirm the categories table exists in your SQLite database schema.

Help me debug my category loading issue

Build the Task Detail screen

The Task Detail screen is where users edit existing tasks and can delete them. It uses a BlocListener to display error messages from the TaskBloc as SnackBars. A confirmation dialog prevents accidental deletions.

  • Create a new file at lib/presentation/screens/task_detail_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/task_entity.dart';
import '../bloc/task/task_bloc.dart';

class TaskDetailScreen extends StatefulWidget {
  final TaskEntity task;
  const TaskDetailScreen({super.key, required this.task});
  @override
  State<TaskDetailScreen> createState() => _TaskDetailScreenState();
}

class _TaskDetailScreenState extends State<TaskDetailScreen> {
  final _formKey = GlobalKey<FormState>();
  late final TextEditingController _titleController;
  late final TextEditingController _descController;
  late String _priority;

  @override
  void initState() {
    super.initState();
    _titleController = TextEditingController(text: widget.task.title);
    _descController = TextEditingController(text: widget.task.description ?? '');
    _priority = widget.task.priority;
  }

  @override
  void dispose() {
    _titleController.dispose();
    _descController.dispose();
    super.dispose();
  }

  void _save() {
    if (_formKey.currentState!.validate()) {
      context.read<TaskBloc>().add(UpdateTaskEvent(widget.task.copyWith(
        title: _titleController.text.trim(),
        description: _descController.text.trim().isEmpty ? null : _descController.text.trim(),
        priority: _priority,
      )));
      Navigator.of(context).pop();
    }
  }

  void _delete() async {
    final confirm = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Delete Task'),
        content: const Text('Are you sure you want to delete this task?'),
        actions: [
          TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
          TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete')),
        ],
      ),
    );
    if (confirm == true && mounted) {
      context.read<TaskBloc>().add(DeleteTaskEvent(widget.task.id!));
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task Detail'),
        actions: [IconButton(icon: const Icon(Icons.delete), onPressed: _delete)],
      ),
      body: BlocListener<TaskBloc, TaskState>(
        listener: (context, state) {
          if (state is TaskError) {
            ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
          }
        },
        child: Form(
          key: _formKey,
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              TextFormField(
                controller: _titleController,
                decoration: const InputDecoration(labelText: 'Title'),
                validator: (v) => v == null || v.trim().isEmpty ? 'Title is required' : null,
              ),
              const SizedBox(height: 12),
              TextFormField(controller: _descController, decoration: const InputDecoration(labelText: 'Description'), maxLines: 3),
              const SizedBox(height: 12),
              DropdownButtonFormField<String>(
                value: _priority,
                decoration: const InputDecoration(labelText: 'Priority'),
                items: ['Low', 'Medium', 'High'].map((p) => DropdownMenuItem(value: p, child: Text(p))).toList(),
                onChanged: (v) => setState(() => _priority = v!),
              ),
              const SizedBox(height: 24),
              ElevatedButton(onPressed: _save, child: const Text('Save Changes')),
            ],
          ),
        ),
      ),
    );
  }
}

What does this code do?

  • The screen receives a TaskEntity via its constructor. Controllers are pre-filled with the task's existing values in initState.
  • The _save() method uses copyWith to create an updated entity and dispatches UpdateTaskEvent. This preserves the task's ID and other unchanged fields.
  • The _delete() method shows a confirmation AlertDialog. Only if the user confirms does it dispatch DeleteTaskEvent with the task's ID.
  • The BlocListener wrapping the form body catches TaskError states and displays them as SnackBars. This is a side effect, so it belongs in a listener rather than a builder.
  • Save task_detail_screen.dart.
  • Hot restart the app. Add a task using the Add Task screen, then tap the task in the list to open the detail screen.
  • You should see the form pre-filled with the task's title, description, and priority. The app bar should display a delete icon on the right.

Task Detail not showing pre-filled values?

Confirm your TaskListScreen navigates to TaskDetailScreen(task: task) and passes the full TaskEntity object. If the title field is empty, the widget.task might be null or empty. Check that your TaskModel's toEntity method correctly maps all fields.

Help me debug my Task Detail screen

Why BlocListener instead of BlocBuilder for errors?

Showing a SnackBar is a one-time side effect, not a persistent UI state. BlocBuilder rebuilds the widget tree on every state change. BlocListener fires once per state change without rebuilding, making it the right choice for navigation, dialogs, and SnackBars.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each of the three screen files.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/task_entity.dart';
import '../bloc/task/task_bloc.dart';

class AddTaskScreen extends StatefulWidget {
  const AddTaskScreen({super.key});
  @override
  State<AddTaskScreen> createState() => _AddTaskScreenState();
}

class _AddTaskScreenState extends State<AddTaskScreen> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _descController = TextEditingController();
  String _priority = 'Medium';
  DateTime? _dueDate;

  @override
  void dispose() {
    _titleController.dispose();
    _descController.dispose();
    super.dispose();
  }

  void _save() {
    if (_formKey.currentState!.validate()) {
      final task = TaskEntity(
        title: _titleController.text.trim(),
        description: _descController.text.trim().isEmpty ? null : _descController.text.trim(),
        priority: _priority,
        dueDate: _dueDate?.toIso8601String(),
        isCompleted: false,
        createdAt: DateTime.now().toIso8601String(),
      );
      context.read<TaskBloc>().add(AddTaskEvent(task));
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Add Task')),
      body: Form(
        key: _formKey,
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            TextFormField(
              controller: _titleController,
              decoration: const InputDecoration(labelText: 'Title'),
              validator: (v) => v == null || v.trim().isEmpty ? 'Title is required' : null,
            ),
            const SizedBox(height: 12),
            TextFormField(
              controller: _descController,
              decoration: const InputDecoration(labelText: 'Description'),
              maxLines: 3,
            ),
            const SizedBox(height: 12),
            DropdownButtonFormField<String>(
              value: _priority,
              decoration: const InputDecoration(labelText: 'Priority'),
              items: ['Low', 'Medium', 'High'].map((p) => DropdownMenuItem(value: p, child: Text(p))).toList(),
              onChanged: (v) => setState(() => _priority = v!),
            ),
            const SizedBox(height: 12),
            ListTile(
              title: Text(_dueDate == null ? 'No due date' : 'Due: ${_dueDate!.toLocal().toString().split(' ')[0]}'),
              trailing: const Icon(Icons.calendar_today),
              onTap: () async {
                final picked = await showDatePicker(
                  context: context,
                  initialDate: DateTime.now(),
                  firstDate: DateTime.now(),
                  lastDate: DateTime(2100),
                );
                if (picked != null) setState(() => _dueDate = picked);
              },
            ),
            const SizedBox(height: 24),
            ElevatedButton(onPressed: _save, child: const Text('Save Task')),
          ],
        ),
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/category_entity.dart';
import '../../domain/usecases/get_categories.dart';
import '../../domain/usecases/add_task.dart';
import '../../injection_container.dart';
import '../../data/repositories/category_repository_impl.dart';
import '../../domain/repositories/category_repository.dart';

class CategoryManagementScreen extends StatefulWidget {
  const CategoryManagementScreen({super.key});
  @override
  State<CategoryManagementScreen> createState() => _CategoryManagementScreenState();
}

class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
  List<CategoryEntity> _categories = [];
  final _nameController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _loadCategories();
  }

  Future<void> _loadCategories() async {
    final result = await sl<GetCategories>()();
    result.fold((_) {}, (cats) => setState(() => _categories = cats));
  }

  Future<void> _addCategory() async {
    if (_nameController.text.trim().isEmpty) return;
    final repo = sl<CategoryRepository>();
    await repo.addCategory(CategoryEntity(name: _nameController.text.trim(), color: '#6750A4'));
    _nameController.clear();
    await _loadCategories();
  }

  Future<void> _deleteCategory(int id) async {
    final repo = sl<CategoryRepository>();
    await repo.deleteCategory(id);
    await _loadCategories();
  }

  @override
  void dispose() { _nameController.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Categories')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(child: TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'Category name'))),
                IconButton(icon: const Icon(Icons.add), onPressed: _addCategory),
              ],
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: _categories.length,
              itemBuilder: (context, index) {
                final cat = _categories[index];
                return ListTile(
                  title: Text(cat.name),
                  trailing: IconButton(
                    icon: const Icon(Icons.delete),
                    onPressed: () => _deleteCategory(cat.id!),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/task_entity.dart';
import '../bloc/task/task_bloc.dart';

class TaskDetailScreen extends StatefulWidget {
  final TaskEntity task;
  const TaskDetailScreen({super.key, required this.task});
  @override
  State<TaskDetailScreen> createState() => _TaskDetailScreenState();
}

class _TaskDetailScreenState extends State<TaskDetailScreen> {
  final _formKey = GlobalKey<FormState>();
  late final TextEditingController _titleController;
  late final TextEditingController _descController;
  late String _priority;

  @override
  void initState() {
    super.initState();
    _titleController = TextEditingController(text: widget.task.title);
    _descController = TextEditingController(text: widget.task.description ?? '');
    _priority = widget.task.priority;
  }

  @override
  void dispose() {
    _titleController.dispose();
    _descController.dispose();
    super.dispose();
  }

  void _save() {
    if (_formKey.currentState!.validate()) {
      context.read<TaskBloc>().add(UpdateTaskEvent(widget.task.copyWith(
        title: _titleController.text.trim(),
        description: _descController.text.trim().isEmpty ? null : _descController.text.trim(),
        priority: _priority,
      )));
      Navigator.of(context).pop();
    }
  }

  void _delete() async {
    final confirm = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Delete Task'),
        content: const Text('Are you sure you want to delete this task?'),
        actions: [
          TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
          TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete')),
        ],
      ),
    );
    if (confirm == true && mounted) {
      context.read<TaskBloc>().add(DeleteTaskEvent(widget.task.id!));
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task Detail'),
        actions: [IconButton(icon: const Icon(Icons.delete), onPressed: _delete)],
      ),
      body: BlocListener<TaskBloc, TaskState>(
        listener: (context, state) {
          if (state is TaskError) {
            ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
          }
        },
        child: Form(
          key: _formKey,
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              TextFormField(
                controller: _titleController,
                decoration: const InputDecoration(labelText: 'Title'),
                validator: (v) => v == null || v.trim().isEmpty ? 'Title is required' : null,
              ),
              const SizedBox(height: 12),
              TextFormField(controller: _descController, decoration: const InputDecoration(labelText: 'Description'), maxLines: 3),
              const SizedBox(height: 12),
              DropdownButtonFormField<String>(
                value: _priority,
                decoration: const InputDecoration(labelText: 'Priority'),
                items: ['Low', 'Medium', 'High'].map((p) => DropdownMenuItem(value: p, child: Text(p))).toList(),
                onChanged: (v) => setState(() => _priority = v!),
              ),
              const SizedBox(height: 24),
              ElevatedButton(onPressed: _save, child: const Text('Save Changes')),
            ],
          ),
        ),
      ),
    );
  }
}

Your app now has a complete task management workflow. Users can create tasks, view and edit details, manage categories, and delete tasks with confirmation. Next up, you'll write tests to verify that all these layers work correctly in isolation.

Write Tests for Use Cases, Repository, and BLoC

Your Task Manager app is fully functional with all Clean Architecture layers connected end-to-end. Now you need to prove it works reliably by writing automated tests.

Testing each layer in isolation is what makes Clean Architecture powerful. You can mock the repository to test use cases, mock use cases to test BLoCs, and mock BLoCs to test widgets. Each test verifies one layer without depending on the layers below it.

In this step, get ready to:
  • Write unit tests for the GetTasks use case with mock objects.
  • Write BLoC tests verifying state transitions on events.
  • Run the full test suite and confirm all tests pass.
Add test dependencies to your project

Before writing any tests, you need two testing packages in your pubspec.yaml file. The mocktail package creates mock objects without code generation, and bloc_test provides the blocTest helper for verifying BLoC state emissions.

  • Open pubspec.yaml in your task_manager project.
  • Add the following under dev_dependencies:
dev_dependencies:
  flutter_test:
    sdk: flutter
  bloc_test: ^10.0.0
  mocktail: ^1.0.5
  • Install the new dependencies by running:
flutter pub get

What are these packages?

The mocktail package lets you create mock implementations of classes by extending Mock. You define when stubs to control what mocks return, then use verify to confirm methods were called.

The bloc_test package provides blocTest, a helper that builds a BLoC, fires an action, and asserts the exact sequence of emitted states. It closes the BLoC stream after the action, guaranteeing no extra states slip through.

Seeing version conflicts?

Make sure your bloc_test version is ^10.0.0 which requires bloc: ^9.0.0. If your flutter_bloc is already ^9.1.1, these are compatible.

help me resolve version conflicts in my pubspec.yaml for bloc_test and flutter_bloc

Write unit tests for the GetTasks use case

Unit testing a use case means verifying it correctly delegates to the repository and passes results through unchanged. You mock the repository so the test never touches a real database.

  • Create the test directory structure by running:
mkdir -p test/domain/usecases
  • Create the file test/domain/usecases/get_tasks_test.dart and add the imports and mock class:
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:task_manager/core/error/failures.dart';
import 'package:task_manager/domain/entities/task_entity.dart';
import 'package:task_manager/domain/repositories/task_repository.dart';
import 'package:task_manager/domain/usecases/get_tasks.dart';

// Mock the TaskRepository interface so tests never touch the real database
class MockTaskRepository extends Mock implements TaskRepository {}

What does this code do?

The MockTaskRepository class extends Mock from mocktail and implements TaskRepository. This gives you a fake version of the repository where you control exactly what getTasks() returns.

The dartz import brings Either, Right, and Left which your repository uses for functional error handling.

  • Now add the main function with setup, test data, and two test cases below the mock class:
void main() {
  late GetTasks useCase;
  late MockTaskRepository mockRepository;

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

  final tTasks = [
    const TaskEntity(id: 1, title: 'Test Task', priority: 'High', isCompleted: false, createdAt: '2024-01-01'),
  ];

  test('should return list of tasks from repository on success', () async {
    when(() => mockRepository.getTasks(categoryId: any(named: 'categoryId'), isCompleted: any(named: 'isCompleted'), priority: any(named: 'priority')))
        .thenAnswer((_) async => Right(tTasks));
    final result = await useCase(const GetTasksParams());
    expect(result, Right(tTasks));
    verify(() => mockRepository.getTasks(categoryId: null, isCompleted: null, priority: null)).called(1);
  });

  test('should return DatabaseFailure when repository fails', () async {
    when(() => mockRepository.getTasks(categoryId: any(named: 'categoryId'), isCompleted: any(named: 'isCompleted'), priority: any(named: 'priority')))
        .thenAnswer((_) async => const Left(DatabaseFailure('DB error')));
    final result = await useCase(const GetTasksParams());
    expect(result, const Left(DatabaseFailure('DB error')));
  });
}

What does this code do?

The setUp function runs before each test, creating a fresh mock and a fresh GetTasks instance. This prevents state from leaking between tests.

The first test stubs getTasks() to return a Right(tTasks) (success), then calls the use case and verifies the result matches. The verify call confirms the repository was called exactly once with the expected parameters.

The second test stubs a Left(DatabaseFailure(...)) return, confirming the use case propagates failures without swallowing them.

  • Save test/domain/usecases/get_tasks_test.dart.
  • Run the use case test by executing:
flutter test test/domain/usecases/get_tasks_test.dart

You should see both tests pass with green checkmarks.

Tests failing with import errors?

Make sure your core/error/failures.dart file exports DatabaseFailure and that GetTasksParams is exported from domain/usecases/get_tasks.dart. The import paths must match your project's package name task_manager exactly.

help me fix import errors in my Flutter unit test file

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:task_manager/core/error/failures.dart';
import 'package:task_manager/domain/entities/task_entity.dart';
import 'package:task_manager/domain/repositories/task_repository.dart';
import 'package:task_manager/domain/usecases/get_tasks.dart';

class MockTaskRepository extends Mock implements TaskRepository {}

void main() {
  late GetTasks useCase;
  late MockTaskRepository mockRepository;

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

  final tTasks = [
    const TaskEntity(id: 1, title: 'Test Task', priority: 'High', isCompleted: false, createdAt: '2024-01-01'),
  ];

  test('should return list of tasks from repository on success', () async {
    when(() => mockRepository.getTasks(categoryId: any(named: 'categoryId'), isCompleted: any(named: 'isCompleted'), priority: any(named: 'priority')))
        .thenAnswer((_) async => Right(tTasks));
    final result = await useCase(const GetTasksParams());
    expect(result, Right(tTasks));
    verify(() => mockRepository.getTasks(categoryId: null, isCompleted: null, priority: null)).called(1);
  });

  test('should return DatabaseFailure when repository fails', () async {
    when(() => mockRepository.getTasks(categoryId: any(named: 'categoryId'), isCompleted: any(named: 'isCompleted'), priority: any(named: 'priority')))
        .thenAnswer((_) async => const Left(DatabaseFailure('DB error')));
    final result = await useCase(const GetTasksParams());
    expect(result, const Left(DatabaseFailure('DB error')));
  });
}
Write BLoC tests with blocTest

BLoC tests verify that when you add an event, the BLoC emits the correct sequence of states. The blocTest helper handles building the BLoC, firing the event, and asserting the emitted states in order.

  • Create the test directory by running:
mkdir -p test/presentation/bloc
  • Create test/presentation/bloc/task_bloc_test.dart and add the imports and mock classes:
import 'package:bloc_test/bloc_test.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:task_manager/core/error/failures.dart';
import 'package:task_manager/domain/entities/task_entity.dart';
import 'package:task_manager/domain/usecases/add_task.dart';
import 'package:task_manager/domain/usecases/delete_task.dart';
import 'package:task_manager/domain/usecases/get_tasks.dart';
import 'package:task_manager/domain/usecases/update_task.dart';
import 'package:task_manager/presentation/bloc/task/task_bloc.dart';

// Mock each use case the TaskBloc depends on
class MockGetTasks extends Mock implements GetTasks {}
class MockAddTask extends Mock implements AddTask {}
class MockUpdateTask extends Mock implements UpdateTask {}
class MockDeleteTask extends Mock implements DeleteTask {}

Why mock every use case?

The TaskBloc depends on four use cases: GetTasks, AddTask, UpdateTask, and DeleteTask. By mocking all of them, you isolate the BLoC layer completely. The tests only verify the BLoC's logic for translating events into states.

  • Add the main function with setup, fallback values, and blocTest cases below the mock classes:
void main() {
  late TaskBloc bloc;
  late MockGetTasks mockGetTasks;
  late MockAddTask mockAddTask;
  late MockUpdateTask mockUpdateTask;
  late MockDeleteTask mockDeleteTask;

  setUpAll(() {
    registerFallbackValue(const GetTasksParams());
    registerFallbackValue(const TaskEntity(title: '', priority: 'Medium', isCompleted: false, createdAt: ''));
  });

  setUp(() {
    mockGetTasks = MockGetTasks();
    mockAddTask = MockAddTask();
    mockUpdateTask = MockUpdateTask();
    mockDeleteTask = MockDeleteTask();
    bloc = TaskBloc(getTasks: mockGetTasks, addTask: mockAddTask, updateTask: mockUpdateTask, deleteTask: mockDeleteTask);
  });

  tearDown(() => bloc.close());

  final tTasks = [const TaskEntity(id: 1, title: 'Task 1', priority: 'High', isCompleted: false, createdAt: '2024-01-01')];

  blocTest<TaskBloc, TaskState>(
    'emits [TaskLoading, TaskLoaded] when LoadTasks succeeds',
    build: () {
      when(() => mockGetTasks(any())).thenAnswer((_) async => Right(tTasks));
      return bloc;
    },
    act: (b) => b.add(const LoadTasks()),
    expect: () => [isA<TaskLoading>(), isA<TaskLoaded>()],
  );

  blocTest<TaskBloc, TaskState>(
    'emits [TaskError] when LoadTasks fails',
    build: () {
      when(() => mockGetTasks(any())).thenAnswer((_) async => const Left(DatabaseFailure('error')));
      return bloc;
    },
    act: (b) => b.add(const LoadTasks()),
    expect: () => [isA<TaskLoading>(), isA<TaskError>()],
  );
}

What does this code do?

The setUpAll block registers fallback values for any() matchers. Mocktail requires this for custom types so it knows what placeholder to use when matching arguments.

Each blocTest call follows the pattern: build creates the BLoC with stubbed dependencies, act fires the event, and expect lists the exact state types emitted in order.

The first test confirms that LoadTasks emits TaskLoading then TaskLoaded on success. The second confirms it emits TaskLoading then TaskError on failure.

  • Save test/presentation/bloc/task_bloc_test.dart.
  • Run the BLoC tests by executing:
flutter test test/presentation/bloc/task_bloc_test.dart

You should see both blocTest cases pass with green checkmarks.

Seeing a MissingStubError?

This happens when registerFallbackValue is missing for a type used with any(). Make sure your setUpAll registers a fallback for GetTasksParams and TaskEntity.

help me fix MissingStubError in my BLoC test with mocktail

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import 'package:bloc_test/bloc_test.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:task_manager/core/error/failures.dart';
import 'package:task_manager/domain/entities/task_entity.dart';
import 'package:task_manager/domain/usecases/add_task.dart';
import 'package:task_manager/domain/usecases/delete_task.dart';
import 'package:task_manager/domain/usecases/get_tasks.dart';
import 'package:task_manager/domain/usecases/update_task.dart';
import 'package:task_manager/presentation/bloc/task/task_bloc.dart';

class MockGetTasks extends Mock implements GetTasks {}
class MockAddTask extends Mock implements AddTask {}
class MockUpdateTask extends Mock implements UpdateTask {}
class MockDeleteTask extends Mock implements DeleteTask {}

void main() {
  late TaskBloc bloc;
  late MockGetTasks mockGetTasks;
  late MockAddTask mockAddTask;
  late MockUpdateTask mockUpdateTask;
  late MockDeleteTask mockDeleteTask;

  setUpAll(() {
    registerFallbackValue(const GetTasksParams());
    registerFallbackValue(const TaskEntity(title: '', priority: 'Medium', isCompleted: false, createdAt: ''));
  });

  setUp(() {
    mockGetTasks = MockGetTasks();
    mockAddTask = MockAddTask();
    mockUpdateTask = MockUpdateTask();
    mockDeleteTask = MockDeleteTask();
    bloc = TaskBloc(getTasks: mockGetTasks, addTask: mockAddTask, updateTask: mockUpdateTask, deleteTask: mockDeleteTask);
  });

  tearDown(() => bloc.close());

  final tTasks = [const TaskEntity(id: 1, title: 'Task 1', priority: 'High', isCompleted: false, createdAt: '2024-01-01')];

  blocTest<TaskBloc, TaskState>(
    'emits [TaskLoading, TaskLoaded] when LoadTasks succeeds',
    build: () {
      when(() => mockGetTasks(any())).thenAnswer((_) async => Right(tTasks));
      return bloc;
    },
    act: (b) => b.add(const LoadTasks()),
    expect: () => [isA<TaskLoading>(), isA<TaskLoaded>()],
  );

  blocTest<TaskBloc, TaskState>(
    'emits [TaskError] when LoadTasks fails',
    build: () {
      when(() => mockGetTasks(any())).thenAnswer((_) async => const Left(DatabaseFailure('error')));
      return bloc;
    },
    act: (b) => b.add(const LoadTasks()),
    expect: () => [isA<TaskLoading>(), isA<TaskError>()],
  );
}
Run the full test suite

With both test files in place, you can run every test in your project at once to confirm nothing is broken.

  • Run all tests across the project by executing:
flutter test

You should see all test cases pass. The output lists each test file and shows green checkmarks for passing tests.

What did you just prove?

Your use case tests prove that GetTasks correctly delegates to TaskRepository and passes both success and failure results through unchanged.

Your BLoC tests prove that TaskBloc emits the correct state sequence (TaskLoading then TaskLoaded or TaskError) in response to events. Each layer is tested in isolation thanks to mock objects.

Expand your test coverage

You can follow the same pattern to add tests for AddTask, UpdateTask, and DeleteTask use cases. For widget tests, use WidgetTester with a mocked BLoC wrapped in BlocProvider to verify that your UI renders a CircularProgressIndicator for TaskLoading and a task list for TaskLoaded.

Your test suite confirms that each layer of your Clean Architecture works correctly in isolation. With mock objects standing in for real dependencies, you can catch bugs at the exact layer they originate from.

Secret mission

Add a Statistics Dashboard

Your Task Manager works great. But how productive are you actually being? Build a Statistics Dashboard that visualizes your task completion rate, category breakdown, and weekly productivity trends. Implement it as a complete clean architecture feature from entity to screen.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine and emulator. There are no cloud resources, no paid APIs, and no recurring costs.

Resources you used:

  • The task_manager project folder on your Desktop.
  • The Task Manager app installed on your emulator or physical device.
  • Local data stored on-device (SQLite database, Hive boxes, SharedPreferences).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the app with more features.

  • Your project folder stays on your Desktop at ~/Desktop/task_manager.
  • The app remains installed on your emulator or device.
  • All task data (SQLite, Hive, SharedPreferences) persists on-device between app launches.

Pause - I'll come back to this later

Shut down running processes to free up system memory. Your files and data stay intact for when you return.

  • Stop the Flutter dev server by pressing q in the terminal where it is running.
  • Close the Android Emulator to free up RAM.
  • Your project folder and all on-device data remain untouched.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your code and any stored task data.

  • Stop the Flutter dev server by pressing q in the terminal if it is still running.
  • Uninstall the Task Manager app from your emulator or device. On Android, long-press the app icon and select Uninstall. On iOS Simulator, long-press and tap Remove App.
  • Delete the project folder by running this command on macOS/Linux:
rm -rf ~/Desktop/task_manager

On Windows, run this in PowerShell:

Remove-Item -Recurse -Force ~\Desktop\task_manager
  • This removes all source code, test files, and build artifacts. On-device data (SQLite file, Hive boxes, SharedPreferences) is removed when you uninstall the app.

Nice Work!

Nice Work!

You just built a professional-grade Flutter application from the ground up. This Task Manager app proves you can architect a mobile application using industry-standard patterns that scale to production codebases.

Here's what you accomplished:

  • Built a complete Task Manager app using Clean Architecture with separate presentation, domain, and data layers, plus BLoC state management using both Cubit and full BLoC patterns.
  • Created a multi-source data layer combining SQLite for relational storage, Hive CE for fast caching, and SharedPreferences for user settings, all coordinated through the Repository Pattern with proper error handling.
  • Wired the entire dependency graph using get_it service locator and built a comprehensive test suite covering use cases, repositories, and BLoCs with mock objects.
  • Added a Statistics Dashboard feature with a circular progress indicator, category distribution breakdown, and weekly bar chart, implemented as a complete clean architecture feature.

Ready to quiz yourself?