Build a Voice Notes App in Flutter

Build an offline voice note app with Clean Architecture, BLoC, and Drift.

Introduction

30 Second Summary

You hear something worth remembering, but by the time you open a notes app and start typing, the thought is already fading. Voice notes solve this problem by letting you capture ideas at the speed of speech.

In this project, you will build Listy, a fully offline voice note-taking app for Android. Listy transcribes your speech into text in real-time, generates summaries of your notes, and stores everything locally on your device.

What You'll Build

Listy is a production-structured Flutter Android app built with Clean Architecture that combines speech recognition, extractive text summarization, and local database persistence.

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

  • A working voice note app that transcribes speech to text in real-time on your Android device.
  • A Clean Architecture Flutter project with proper layer separation, BLoC state management, get_it dependency injection, and dartz error handling.
  • Notes persisted in a local Drift SQLite database with extractive text summarization powered by pure Dart.
  • Secret Mission: Add search functionality to filter notes by keyword.

Are there any prerequisites?

You'll need Flutter SDK (Dart SDK 3.10+), Android Studio, and VS Code already installed on your machine. You'll also need an Android device for testing.

Create the Flutter Project and Configure Dependencies

Every well-built app starts with a solid foundation. Before writing any feature code, you need a project structure that keeps your code organized as it grows.

In this step, you will create a new Flutter project called listy, install all the packages it needs, and set up the folder structure that Clean Architecture requires.

In this step, get ready to:
  • Create the Flutter project and configure its dependencies.
  • Set up the Clean Architecture folder structure.
  • Configure Android permissions and build settings.
Create the Flutter project and install packages

Flutter's CLI can scaffold an entire project in one command. You will use it to create a project named listy, then replace the default dependency list with the packages this app needs.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Flutter project and move into it by running these commands:
flutter create listy
cd listy

What does this command do?

flutter create listy scaffolds a complete Flutter project with Android, iOS, and web support in a folder called listy.

cd listy moves your terminal into that new folder so all future commands run from the project root.

  • Confirm the project was created by running:
ls
  • You should see folders like android, ios, lib, and files like pubspec.yaml in the output.

Don't see the project files?

Make sure you ran cd listy after flutter create listy. If you see a permission error, check that your Flutter SDK is on your system PATH.

help me troubleshoot why flutter create is not working

  • Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the listy folder, then click Open.

Now you need to replace the default pubspec.yaml with one that declares all the packages Listy needs.

  • In the VS Code Explorer sidebar, click pubspec.yaml to open it.
  • Select all existing content (Cmd+A on macOS or Ctrl+A on Windows) and replace it with the following:
name: listy
description: A voice note-taking app.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.10.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  speech_to_text: ^6.6.0
  ml_text_summarizer: ^1.0.0
  drift: ^2.14.1
  drift_flutter: ^0.1.0
  flutter_bloc: ^8.1.3
  bloc: ^8.1.2
  get_it: ^7.6.4
  dartz: ^0.10.1
  path_provider: ^2.1.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.7
  drift_dev: ^2.14.1
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true

What are these packages for?

  • speech_to_text handles microphone input and speech recognition.
  • ml_text_summarizer extracts key sentences from transcribed text.
  • drift and drift_flutter provide a type-safe local SQLite database.
  • flutter_bloc and bloc manage app state with the BLoC pattern.
  • get_it is a service locator for dependency injection.
  • dartz gives you the Either type for functional error handling.
  • build_runner and drift_dev are dev-only tools that generate Drift database code.
  • Save pubspec.yaml.
  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Install all the packages by running:
flutter pub get
  • You should see output ending with a message confirming that all packages were resolved successfully.

Seeing dependency resolution errors?

Double-check that your pubspec.yaml indentation uses spaces, not tabs. YAML is whitespace-sensitive. Also confirm your Dart SDK is 3.10+ by running dart --version.

help me fix flutter pub get errors

Set up the Clean Architecture folder structure

Clean Architecture separates your code into layers: domain (business logic), data (databases and APIs), and presentation (UI). Creating these folders now keeps your code organized from the start.

  • Create the folder structure by running these commands in the VS Code terminal:
mkdir -p lib/core
mkdir -p lib/features/notes/data
mkdir -p lib/features/notes/domain
mkdir -p lib/features/notes/presentation

What is Clean Architecture?

Clean Architecture organizes code so that business rules (domain) never depend on frameworks or databases (data). The presentation layer holds UI and state management. The domain layer holds entities and use cases. The data layer holds database implementations and API calls.

The core folder is for shared utilities and error classes used across features.

  • Confirm the folders were created by running:
ls lib/features/notes
  • You should see data, domain, and presentation listed in the output.

Folders not appearing in VS Code?

VS Code sometimes collapses empty folders in the Explorer sidebar. Click the refresh icon at the top of the Explorer panel. You can also expand lib > features > notes manually to verify.

help me verify my folder structure is correct

Configure Android permissions and build settings

The speech_to_text package needs microphone access on Android. You also need to ensure the project compiles against Android SDK 34 so all dependencies resolve correctly.

  • In the VS Code Explorer, expand android > app > src > main and click AndroidManifest.xml to open it.
  • Find the opening <manifest> tag. Add these two permission lines directly below it, before the <application tag:
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.INTERNET"/>

Why both RECORD_AUDIO and INTERNET?

RECORD_AUDIO lets the app access the microphone for speech recognition. INTERNET is needed for the initial download of the speech recognition language model on some devices. After that first download, recognition works fully offline.

  • Save AndroidManifest.xml.

✔️ Awesome, I've got everything!

Great. Double check you saved AndroidManifest.xml and move on to the build settings below.

ⓧ I'd like to double check the full code

Your full AndroidManifest.xml should look like this:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <uses-permission android:name="android.permission.RECORD_AUDIO"/>
  <uses-permission android:name="android.permission.INTERNET"/>
  <application
    android:label="listy"
    android:name="${applicationName}"
    android:icon="@mipmap/ic_launcher">
    <activity
      android:name=".MainActivity"
      android:exported="true"
      android:launchMode="singleTop"
      android:theme="@style/LaunchTheme"
      android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
      android:hardwareAccelerated="true"
      android:windowSoftInputMode="adjustResize">
      <meta-data
        android:name="io.flutter.embedding.android.NormalTheme"
        android:resource="@style/NormalTheme"/>
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>
    </activity>
    <meta-data
      android:name="flutterEmbedding"
      android:value="2"/>
  </application>
</manifest>

Now you need to set the Android compile SDK version to 34. This ensures all your dependencies can compile without errors.

  • In the VS Code Explorer, navigate to android > app and open build.gradle.kts (or build.gradle if your project uses the Groovy format).
  • Find the line containing compileSdk inside the android block. It looks like this:
compileSdk = flutter.compileSdkVersion
  • Replace that line with a hardcoded value of 34, so it now looks like this:
compileSdk = 34

Why override the default?

The speech_to_text package requires compileSdkVersion 31 or higher. Setting it explicitly to 34 guarantees compatibility with all dependencies and avoids build errors from SDK version mismatches.

  • Save the file.

Build failing after this change?

If you see errors about NDK version or Gradle plugin version, make sure your Android Studio has SDK Platform 34 installed. Open Android Studio, go to SDK Manager, and check that Android 14.0 (API 34) is downloaded.

help me fix Android SDK build errors in my Flutter project

Your project is scaffolded and ready for real code. Next up, you will build the domain and data layers that define what a Note is and how it gets stored.

Build the Domain and Data Layers

With your project structure in place, it's time to define what your app does before deciding how it does it. Clean Architecture separates these concerns into distinct layers. The domain layer defines your business rules. The data layer implements those rules with real databases and services.

In this step, you'll build both layers from the ground up. You'll start with the domain layer (the contracts and entities your app needs) then implement the data layer (the Drift database and repository that fulfill those contracts).

In this step, get ready to:
  • Create the domain layer with entities, failure types, a repository contract, and use cases.
  • Create the data layer with a Drift database, DAO, and repository implementation.
  • Run Drift code generation to produce the database helper file.
Create the domain layer

The domain layer is the heart of Clean Architecture. It contains your business logic with zero knowledge of databases, APIs, or UI frameworks.

What is the domain layer?

The domain layer defines what your app can do (use cases), what data looks like (entities), and what contracts other layers must follow (repository interfaces). It never imports Flutter, Drift, or any framework.

This means you could swap your database from Drift to Firebase without touching a single domain file.

You'll create subdirectories inside the existing folders as you go. VS Code creates parent directories automatically when you type a full path for a new file.

  • In VS Code's Explorer sidebar, right-click on the lib/core/ folder.
  • Select New File.
  • Type error/failures.dart and press Enter.
  • Paste the following code into failures.dart:
abstract class Failure {
  final String message;
  const Failure(this.message);
}

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

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

What does this code do?

The abstract Failure class is a base type for all errors in your app. Instead of throwing exceptions everywhere, you'll wrap errors in typed failure objects.

DatabaseFailure represents local storage errors. ServerFailure represents processing errors (like summarization failing). Each carries a human-readable message.

  • Save failures.dart.
  • Right-click on lib/features/notes/domain/ and select New File.
  • Type entities/note.dart and press Enter.
  • Paste the following code into note.dart:
class Note {
  final int id;
  final String content;
  final String? summary;
  final DateTime createdAt;

  const Note({
    required this.id,
    required this.content,
    this.summary,
    required this.createdAt,
  });
}

What does this code do?

The Note entity is a plain Dart class representing a voice note in your app. It has no database annotations or framework dependencies.

The summary field is nullable (String?) because a note starts without a summary. The user generates one later.

  • Save note.dart.
  • Right-click on lib/features/notes/domain/ and select New File.
  • Type repositories/note_repository.dart and press Enter.
  • Paste the following code into note_repository.dart:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/note.dart';

abstract class NoteRepository {
  Future<Either<Failure, List<Note>>> getNotes();
  Future<Either<Failure, Unit>> addNote(String content, String? summary);
  Future<Either<Failure, Unit>> deleteNote(int id);
  Future<Either<Failure, String>> summarizeText(String text);
}

What is Either from dartz?

dartz is a functional programming library for Dart. Its Either<L, R> type represents a value that is either a Left (error/failure) or a Right (success).

Instead of using try-catch throughout your app, every repository method returns Either<Failure, SuccessType>. Callers check which side they got and handle both cases explicitly. No exceptions slip through unhandled.

  • Save note_repository.dart.

Now you'll create the use cases. Each use case wraps a single repository method, giving your app a clear, named action for each operation.

  • Right-click on lib/features/notes/domain/ and create a new file: usecases/get_notes.dart.
  • Paste the following code:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/note.dart';
import '../repositories/note_repository.dart';

class GetNotes {
  final NoteRepository repository;
  GetNotes(this.repository);

  Future<Either<Failure, List<Note>>> call() => repository.getNotes();
}

What is a use case in Clean Architecture?

A use case is a single action your app can perform. It takes a repository, calls one method, and returns the result. This gives your BLoC layer named operations to call instead of touching the repository directly.

The call() method is a Dart convention that lets you invoke the class like a function: getNotesUseCase() instead of getNotesUseCase.call().

  • Save get_notes.dart.
  • Create a new file: lib/features/notes/domain/usecases/add_note.dart.
  • Paste the following code:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../repositories/note_repository.dart';

class AddNote {
  final NoteRepository repository;
  AddNote(this.repository);

  Future<Either<Failure, Unit>> call(String content, String? summary) =>
      repository.addNote(content, summary);
}
  • Save add_note.dart.
  • Create a new file: lib/features/notes/domain/usecases/delete_note.dart.
  • Paste the following code:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../repositories/note_repository.dart';

class DeleteNote {
  final NoteRepository repository;
  DeleteNote(this.repository);

  Future<Either<Failure, Unit>> call(int id) => repository.deleteNote(id);
}
  • Save delete_note.dart.
  • Create a new file: lib/features/notes/domain/usecases/summarize_text.dart.
  • Paste the following code:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../repositories/note_repository.dart';

class SummarizeText {
  final NoteRepository repository;
  SummarizeText(this.repository);

  Future<Either<Failure, String>> call(String text) =>
      repository.summarizeText(text);
}
  • Save summarize_text.dart.
  • Confirm your domain folder structure in the Explorer sidebar. You should see entities/, repositories/, and usecases/ subfolders under lib/features/notes/domain/.

Seeing red underlines in the repository file?

If note_repository.dart shows import errors, make sure failures.dart and note.dart are saved. Dart's analyzer resolves imports in real-time.

Also check that your file paths match exactly. The relative import ../../../../core/error/failures.dart relies on the file being in the correct subdirectory.

Ask for help: Why am I seeing import errors in my domain layer files?

✔️ Awesome, I've got everything!

Great. Double check you've saved all seven domain layer files before moving on.

ⓧ I'd like to double check the full code

abstract class Failure {
  final String message;
  const Failure(this.message);
}

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

class ServerFailure extends Failure {
  const ServerFailure(super.message);
}
class Note {
  final int id;
  final String content;
  final String? summary;
  final DateTime createdAt;

  const Note({
    required this.id,
    required this.content,
    this.summary,
    required this.createdAt,
  });
}
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/note.dart';

abstract class NoteRepository {
  Future<Either<Failure, List<Note>>> getNotes();
  Future<Either<Failure, Unit>> addNote(String content, String? summary);
  Future<Either<Failure, Unit>> deleteNote(int id);
  Future<Either<Failure, String>> summarizeText(String text);
}
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/note.dart';
import '../repositories/note_repository.dart';

class GetNotes {
  final NoteRepository repository;
  GetNotes(this.repository);

  Future<Either<Failure, List<Note>>> call() => repository.getNotes();
}
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../repositories/note_repository.dart';

class AddNote {
  final NoteRepository repository;
  AddNote(this.repository);

  Future<Either<Failure, Unit>> call(String content, String? summary) =>
      repository.addNote(content, summary);
}
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../repositories/note_repository.dart';

class DeleteNote {
  final NoteRepository repository;
  DeleteNote(this.repository);

  Future<Either<Failure, Unit>> call(int id) => repository.deleteNote(id);
}
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../repositories/note_repository.dart';

class SummarizeText {
  final NoteRepository repository;
  SummarizeText(this.repository);

  Future<Either<Failure, String>> call(String text) =>
      repository.summarizeText(text);
}
Create the data layer

The data layer implements the contracts defined in your domain layer. This is where the actual database lives.

You'll use Drift, a reactive persistence library that generates type-safe Dart code from your table definitions.

  • Right-click on lib/features/notes/data/ and select New File.
  • Type datasources/app_database.dart and press Enter.
  • Paste the following code into app_database.dart:
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'app_database.g.dart';

class Notes extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get content => text()();
  TextColumn get summary => text().nullable()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

@DriftDatabase(tables: [Notes], daos: [NoteDao])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(driftDatabase(name: 'listy_db'));

  @override
  int get schemaVersion => 1;
}

@DriftAccessor(tables: [Notes])
class NoteDao extends DatabaseAccessor<AppDatabase> with _$NoteDaoMixin {
  NoteDao(super.db);

  Future<List<Note>> getAllNotes() => select(notes).get();

  Future<int> insertNote(NotesCompanion entry) => into(notes).insert(entry);

  Future<int> deleteNoteById(int id) =>
      (delete(notes)..where((t) => t.id.equals(id))).go();
}

What does this code do?

The Notes class defines your database table schema. Each getter (id, content, summary, createdAt) maps to a column in the SQLite table.

The AppDatabase class extends a generated _$AppDatabase superclass (created by build_runner). The part 'app_database.g.dart' directive tells Drift where to put the generated code.

The NoteDao (Data Access Object) contains your CRUD queries. It keeps database operations organized in one place rather than scattering them across the codebase.

  • Save app_database.dart. You'll see red underlines on _$AppDatabase and _$NoteDaoMixin. That's expected. These classes don't exist yet. They'll be generated in the next substep.
  • Right-click on lib/features/notes/data/ and create a new file: repositories/note_repository_impl.dart.
  • Paste the following code into note_repository_impl.dart:
import 'package:dartz/dartz.dart';
import 'package:drift/drift.dart' show Value;
import 'package:ml_text_summarizer/ml_text_summarizer.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/note.dart';
import '../../domain/repositories/note_repository.dart';
import '../datasources/app_database.dart';

class NoteRepositoryImpl implements NoteRepository {
  final NoteDao noteDao;

  NoteRepositoryImpl(this.noteDao);

  @override
  Future<Either<Failure, List<Note>>> getNotes() async {
    try {
      final rows = await noteDao.getAllNotes();
      final notes = rows
          .map((r) => Note(
                id: r.id,
                content: r.content,
                summary: r.summary,
                createdAt: r.createdAt,
              ))
          .toList();
      return Right(notes);
    } catch (e) {
      return Left(DatabaseFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, Unit>> addNote(String content, String? summary) async {
    try {
      await noteDao.insertNote(
        NotesCompanion(
          content: Value(content),
          summary: Value(summary),
        ),
      );
      return const Right(unit);
    } catch (e) {
      return Left(DatabaseFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, Unit>> deleteNote(int id) async {
    try {
      await noteDao.deleteNoteById(id);
      return const Right(unit);
    } catch (e) {
      return Left(DatabaseFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, String>> summarizeText(String text) async {
    try {
      final summarizer = TextSummarizer();
      final summary = summarizer.summarize(text);
      return Right(summary);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}

What does this code do?

NoteRepositoryImpl is where the domain contract meets reality. It implements every method from NoteRepository using the actual NoteDao for database calls.

Every method wraps its logic in a try-catch block. Success returns Right(value). Failure returns Left(DatabaseFailure(...)) or Left(ServerFailure(...)). This keeps error handling consistent throughout the app.

The summarizeText method uses TextSummarizer from the ml_text_summarizer package. It extracts key sentences from the transcribed text using word frequency scoring.

  • Save note_repository_impl.dart.

Seeing errors in note_repository_impl.dart?

If you see errors on NotesCompanion or Note (the Drift-generated one vs your entity), that's expected. The app_database.g.dart file hasn't been generated yet. These errors will resolve after the next substep.

Ask for help: Why is NoteRepositoryImpl showing errors before code generation?

✔️ Awesome, I've got everything!

Great. Double check you've saved both data layer files before moving on to code generation.

ⓧ I'd like to double check the full code

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'app_database.g.dart';

class Notes extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get content => text()();
  TextColumn get summary => text().nullable()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

@DriftDatabase(tables: [Notes], daos: [NoteDao])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(driftDatabase(name: 'listy_db'));

  @override
  int get schemaVersion => 1;
}

@DriftAccessor(tables: [Notes])
class NoteDao extends DatabaseAccessor<AppDatabase> with _$NoteDaoMixin {
  NoteDao(super.db);

  Future<List<Note>> getAllNotes() => select(notes).get();

  Future<int> insertNote(NotesCompanion entry) => into(notes).insert(entry);

  Future<int> deleteNoteById(int id) =>
      (delete(notes)..where((t) => t.id.equals(id))).go();
}
import 'package:dartz/dartz.dart';
import 'package:drift/drift.dart' show Value;
import 'package:ml_text_summarizer/ml_text_summarizer.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/note.dart';
import '../../domain/repositories/note_repository.dart';
import '../datasources/app_database.dart';

class NoteRepositoryImpl implements NoteRepository {
  final NoteDao noteDao;

  NoteRepositoryImpl(this.noteDao);

  @override
  Future<Either<Failure, List<Note>>> getNotes() async {
    try {
      final rows = await noteDao.getAllNotes();
      final notes = rows
          .map((r) => Note(
                id: r.id,
                content: r.content,
                summary: r.summary,
                createdAt: r.createdAt,
              ))
          .toList();
      return Right(notes);
    } catch (e) {
      return Left(DatabaseFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, Unit>> addNote(String content, String? summary) async {
    try {
      await noteDao.insertNote(
        NotesCompanion(
          content: Value(content),
          summary: Value(summary),
        ),
      );
      return const Right(unit);
    } catch (e) {
      return Left(DatabaseFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, Unit>> deleteNote(int id) async {
    try {
      await noteDao.deleteNoteById(id);
      return const Right(unit);
    } catch (e) {
      return Left(DatabaseFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, String>> summarizeText(String text) async {
    try {
      final summarizer = TextSummarizer();
      final summary = summarizer.summarize(text);
      return Right(summary);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}
Run Drift code generation

Drift uses code generation to create the database implementation from your table definitions. The build_runner tool reads your annotations and generates app_database.g.dart.

  • Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Make sure you're in the listy project root (where pubspec.yaml lives).
  • Generate the Drift database code by running this command:
dart run build_runner build
  • Wait for the command to finish. You should see output ending with a line that mentions succeeded and the number of outputs generated.
  • Check the Explorer sidebar. A new file app_database.g.dart should appear alongside app_database.dart in lib/features/notes/data/datasources/.
  • Return to app_database.dart and note_repository_impl.dart. The red underlines should now be gone.

Build runner failed or showing errors?

If you see conflicting output errors, run the command with the delete flag:

dart run build_runner build --delete-conflicting-outputs

If the command fails with a package resolution error, run flutter pub get first to ensure all dependencies are installed.

Ask for help: Why is build_runner failing in my Flutter Drift project?

Your domain and data layers are complete. Every operation your app needs (fetching, saving, deleting notes, and summarizing text) has a clean contract and a working implementation. Next up, you'll wire these layers together with dependency injection and add the speech service.

Set Up Dependency Injection and Services

Your domain and data layers are built. You have entities, use cases, a repository, and a database. But right now, nothing connects them together.

In this step, you will create service wrappers for speech recognition and text summarization. Then you will wire everything together using a dependency injection container so that any part of the app can request the objects it needs without manually constructing them.

In this step, get ready to:
  • Create a SpeechService wrapper around the speech_to_text plugin.
  • Create a SummarizationService wrapper around ml_text_summarizer.
  • Set up the get_it injection container to register all dependencies.
Create the SpeechService wrapper

The speech_to_text plugin exposes a low-level API with initialize(), listen(), and stop() methods. Wrapping these in a service class gives you a single place to handle errors with dartz Either and keeps your BLoC layer decoupled from the plugin directly.

  • Create a new folder at lib/features/notes/data/services/
  • Create a new file called speech_service.dart inside that folder and add the following code:
import 'package:dartz/dartz.dart';
import 'package:speech_to_text/speech_to_text.dart';
import '../../../../core/error/failures.dart';

class SpeechService {
  final SpeechToText _speechToText = SpeechToText();
  bool _isInitialized = false;

  // Sets up the speech recognition engine once per session
  Future<Either<Failure, Unit>> initialize() async {
    try {
      _isInitialized = await _speechToText.initialize();
      if (_isInitialized) {
        return const Right(unit);
      } else {
        return const Left(ServerFailure('Speech recognition not available'));
      }
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  // Starts listening and passes recognized words to the callback
  Future<Either<Failure, Unit>> startListening(
      void Function(String text) onResult) async {
    try {
      if (!_isInitialized) {
        final result = await initialize();
        if (result.isLeft()) return result;
      }
      await _speechToText.listen(
        onResult: (result) => onResult(result.recognizedWords),
      );
      return const Right(unit);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  // Stops an active listening session
  Future<Either<Failure, Unit>> stopListening() async {
    try {
      await _speechToText.stop();
      return const Right(unit);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  bool get isListening => _speechToText.isListening;
}

What does this code do?

The SpeechService class wraps the SpeechToText plugin behind a clean interface that returns Either<Failure, Unit> on every operation.

  • initialize() sets up speech recognition once per app session. If the device denies permission or lacks support, it returns a Left(ServerFailure(...)).
  • startListening() auto-initializes if needed, then begins listening. Recognized words flow through the onResult callback in real time.
  • stopListening() ends the active session so the microphone is released.
  • Save speech_service.dart.

You won't see a visible change yet. This service will be used by the BLoC layer in the next step.

Seeing an import error on speech_to_text?

Make sure you ran flutter pub get after updating your pubspec.yaml in Step 1. The speech_to_text package needs to be downloaded before Dart can resolve its import.

Help me fix my speech_to_text import error in my Flutter project.

Create the SummarizationService wrapper

The ml_text_summarizer package performs extractive summarization. It picks the most important sentences from a block of text based on word frequency scoring. Wrapping it in a service gives you consistent Either-based error handling.

  • Create a new file called summarization_service.dart inside lib/features/notes/data/services/ and add the following code:
import 'package:dartz/dartz.dart';
import 'package:ml_text_summarizer/ml_text_summarizer.dart';
import '../../../../core/error/failures.dart';

class SummarizationService {
  final TextSummarizer _summarizer = TextSummarizer();

  // Extracts key sentences from the provided text
  Either<Failure, String> summarize(String text) {
    try {
      final summary = _summarizer.summarize(text);
      return Right(summary);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}

What does this code do?

SummarizationService wraps the TextSummarizer class. It calls summarize() to extract key sentences and returns the result as Either<Failure, String>. If summarization fails for any reason, it catches the exception and wraps it in a ServerFailure.

Notice this method is synchronous (not async). The extractive algorithm runs purely on CPU without any network calls, so no Future is needed.

  • Save summarization_service.dart.

You won't see a visible change yet. This service will be called when the user taps the Summarize button in a later step.

Seeing an error on TextSummarizer?

Confirm that ml_text_summarizer: ^1.0.0 is listed under dependencies in your pubspec.yaml and that you have run flutter pub get.

Help me resolve my ml_text_summarizer import error.

Set up the dependency injection container

Right now your services, repository, and use cases are isolated classes. Without a central registry, you would manually construct each object and pass dependencies by hand everywhere.

get_it is a service locator that lets you register all your dependencies in one place. Any part of the app can then request what it needs by type, without knowing how to construct it.

  • Create a new file at lib/injection_container.dart and add the following code:
import 'package:get_it/get_it.dart';
import 'features/notes/data/datasources/app_database.dart';
import 'features/notes/data/repositories/note_repository_impl.dart';
import 'features/notes/data/services/speech_service.dart';
import 'features/notes/data/services/summarization_service.dart';
import 'features/notes/domain/repositories/note_repository.dart';
import 'features/notes/domain/usecases/add_note.dart';
import 'features/notes/domain/usecases/delete_note.dart';
import 'features/notes/domain/usecases/get_notes.dart';
import 'features/notes/domain/usecases/summarize_text.dart';

final sl = GetIt.instance;

Future<void> init() async {
  // Database
  sl.registerLazySingleton<AppDatabase>(() => AppDatabase());
  sl.registerLazySingleton<NoteDao>(() => sl<AppDatabase>().noteDao);

  // Repository
  sl.registerLazySingleton<NoteRepository>(
    () => NoteRepositoryImpl(sl<NoteDao>()),
  );

  // Use cases
  sl.registerLazySingleton(() => GetNotes(sl<NoteRepository>()));
  sl.registerLazySingleton(() => AddNote(sl<NoteRepository>()));
  sl.registerLazySingleton(() => DeleteNote(sl<NoteRepository>()));
  sl.registerLazySingleton(() => SummarizeText(sl<NoteRepository>()));

  // Services
  sl.registerLazySingleton(() => SpeechService());
  sl.registerLazySingleton(() => SummarizationService());
}

What does this code do?

This file is the single wiring point for the entire app. The sl variable (short for "service locator") is the global GetIt instance.

  • registerLazySingleton delays creating each object until the first time it is requested. This keeps app startup fast.
  • Each registration declares its dependencies via sl<Type>() calls. For example, NoteRepositoryImpl asks for a NoteDao, which in turn asks for an AppDatabase.
  • When you call init() in main.dart (next step), every dependency is registered and ready to be fetched by the BLoC layer.

Why registerLazySingleton instead of registerFactory?

A lazy singleton creates the object once and reuses it forever. This is ideal for stateful services like your database and speech recognizer. You want exactly one AppDatabase connection and one SpeechService instance for the lifetime of the app.

A factory would create a new instance every time it is requested. BLoCs will use registerFactory in the next step because each screen needs its own BLoC instance.

  • Save injection_container.dart.
  • Confirm there are no red underlines in VS Code. All imports should resolve to files you have already created.

Seeing unresolved import errors?

Make sure the file paths in your imports match the folder structure you created in Step 1. In particular, app_database.dart lives at lib/features/notes/data/datasources/ and both service files live at lib/features/notes/data/services/.

Help me fix unresolved imports in my injection_container.dart file.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete code for each new file you created in this step.

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

class SpeechService {
  final SpeechToText _speechToText = SpeechToText();
  bool _isInitialized = false;

  Future<Either<Failure, Unit>> initialize() async {
    try {
      _isInitialized = await _speechToText.initialize();
      if (_isInitialized) {
        return const Right(unit);
      } else {
        return const Left(ServerFailure('Speech recognition not available'));
      }
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  Future<Either<Failure, Unit>> startListening(
      void Function(String text) onResult) async {
    try {
      if (!_isInitialized) {
        final result = await initialize();
        if (result.isLeft()) return result;
      }
      await _speechToText.listen(
        onResult: (result) => onResult(result.recognizedWords),
      );
      return const Right(unit);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  Future<Either<Failure, Unit>> stopListening() async {
    try {
      await _speechToText.stop();
      return const Right(unit);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  bool get isListening => _speechToText.isListening;
}
import 'package:dartz/dartz.dart';
import 'package:ml_text_summarizer/ml_text_summarizer.dart';
import '../../../../core/error/failures.dart';

class SummarizationService {
  final TextSummarizer _summarizer = TextSummarizer();

  Either<Failure, String> summarize(String text) {
    try {
      final summary = _summarizer.summarize(text);
      return Right(summary);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}
import 'package:get_it/get_it.dart';
import 'features/notes/data/datasources/app_database.dart';
import 'features/notes/data/repositories/note_repository_impl.dart';
import 'features/notes/data/services/speech_service.dart';
import 'features/notes/data/services/summarization_service.dart';
import 'features/notes/domain/repositories/note_repository.dart';
import 'features/notes/domain/usecases/add_note.dart';
import 'features/notes/domain/usecases/delete_note.dart';
import 'features/notes/domain/usecases/get_notes.dart';
import 'features/notes/domain/usecases/summarize_text.dart';

final sl = GetIt.instance;

Future<void> init() async {
  // Database
  sl.registerLazySingleton<AppDatabase>(() => AppDatabase());
  sl.registerLazySingleton<NoteDao>(() => sl<AppDatabase>().noteDao);

  // Repository
  sl.registerLazySingleton<NoteRepository>(
    () => NoteRepositoryImpl(sl<NoteDao>()),
  );

  // Use cases
  sl.registerLazySingleton(() => GetNotes(sl<NoteRepository>()));
  sl.registerLazySingleton(() => AddNote(sl<NoteRepository>()));
  sl.registerLazySingleton(() => DeleteNote(sl<NoteRepository>()));
  sl.registerLazySingleton(() => SummarizeText(sl<NoteRepository>()));

  // Services
  sl.registerLazySingleton(() => SpeechService());
  sl.registerLazySingleton(() => SummarizationService());
}

Your services are wrapped and your entire dependency graph is registered. Next up, you will build the BLoC layer that uses these services to manage state across your screens.

Build the BLoC Layer

Your domain and data layers handle business logic and persistence. But the UI still needs a way to request data, react to changes, and manage screen-specific state.

The BLoC (Business Logic Component) pattern solves this by processing events into state changes. In this step, you'll create two BLoCs: one for the notes list screen and one for the voice recording flow.

In this step, get ready to:
  • Create the NoteListBloc to manage loading and deleting notes.
  • Create the VoiceNoteBloc to manage voice recording, summarization, and saving.
  • Register both BLoCs in the dependency injection container.
Create the NoteListBloc

The NoteListBloc manages the notes list screen. It handles two user actions: loading all saved notes and deleting a single note.

In the BLoC pattern, you define events (what the user wants to do) and states (what the UI should display). The BLoC class connects the two by processing each event into a new state.

  • Create a new folder at lib/features/notes/presentation/bloc/note_list/ in your VS Code Explorer sidebar.

Creating nested folders in VS Code

Right-click the presentation folder, select New Folder, and type bloc/note_list. VS Code creates both directories at once.

  • Create a new file called note_list_event.dart inside the note_list/ folder and add this code:
part of 'note_list_bloc.dart';

abstract class NoteListEvent {}

class LoadNotes extends NoteListEvent {}

class DeleteNoteEvent extends NoteListEvent {
  final int id;
  DeleteNoteEvent(this.id);
}

What does this code do?

This file defines the events that the NoteListBloc can respond to. LoadNotes triggers fetching all notes from the database. DeleteNoteEvent carries the id of the note to remove.

The part of directive links this file to the main bloc file. Dart treats all part files as a single library, so classes can reference each other freely.

  • Save note_list_event.dart.
  • You may see a red underline on the part of line. That resolves once you create the bloc file shortly.
  • Create a new file called note_list_state.dart in the same note_list/ folder and add this code:
part of 'note_list_bloc.dart';

abstract class NoteListState {}

class NoteListInitial extends NoteListState {}

class NoteListLoading extends NoteListState {}

class NoteListLoaded extends NoteListState {
  final List<Note> notes;
  NoteListLoaded(this.notes);
}

class NoteListError extends NoteListState {
  final String message;
  NoteListError(this.message);
}

What does this code do?

Each state class represents a distinct screen condition. NoteListInitial is the default before anything happens. NoteListLoading indicates a fetch is in progress.

NoteListLoaded carries the actual list of Note objects for the UI to display. NoteListError carries a message when something goes wrong.

  • Save note_list_state.dart.
  • Create a new file called note_list_bloc.dart in the same note_list/ folder and add this code:
import 'package:bloc/bloc.dart';
import '../../../domain/entities/note.dart';
import '../../../domain/usecases/delete_note.dart';
import '../../../domain/usecases/get_notes.dart';

part 'note_list_event.dart';
part 'note_list_state.dart';

class NoteListBloc extends Bloc<NoteListEvent, NoteListState> {
  final GetNotes getNotes;
  final DeleteNote deleteNote;

  NoteListBloc({required this.getNotes, required this.deleteNote})
      : super(NoteListInitial()) {
    on<LoadNotes>(_onLoadNotes);
    on<DeleteNoteEvent>(_onDeleteNote);
  }

  Future<void> _onLoadNotes(
      LoadNotes event, Emitter<NoteListState> emit) async {
    emit(NoteListLoading());
    final result = await getNotes();
    result.fold(
      (failure) => emit(NoteListError(failure.message)),
      (notes) => emit(NoteListLoaded(notes)),
    );
  }

  Future<void> _onDeleteNote(
      DeleteNoteEvent event, Emitter<NoteListState> emit) async {
    final result = await deleteNote(event.id);
    result.fold(
      (failure) => emit(NoteListError(failure.message)),
      (_) => add(LoadNotes()),
    );
  }
}

What does this code do?

The on<EventType> method registers a handler for each event. When LoadNotes fires, the bloc emits NoteListLoading first, then calls the GetNotes use case.

The result.fold() pattern comes from dartz. It unwraps the Either type: the left side handles failures, the right side handles success.

After a successful delete, the bloc dispatches LoadNotes() to itself using add(). This refreshes the list automatically so the UI stays in sync.

  • Save note_list_bloc.dart.
  • Check that all red underlines in the three note_list/ files disappear. The part directives now resolve correctly.

Still seeing red underlines?

Make sure all three files (note_list_event.dart, note_list_state.dart, note_list_bloc.dart) are saved and in the same note_list/ folder.

Check that the part of 'note_list_bloc.dart'; line in both event and state files matches the bloc filename exactly, including the quotes and semicolon.

help me fix red underlines in my NoteListBloc files

Create the VoiceNoteBloc

The VoiceNoteBloc manages the entire voice recording flow: starting and stopping the microphone, updating the transcription in real time, summarizing the text, and saving the note.

  • Create a new folder at lib/features/notes/presentation/bloc/voice_note/ using the same approach as before (right-click bloc, New Folder, type voice_note).
  • Create voice_note_event.dart inside the voice_note/ folder and add this code:
part of 'voice_note_bloc.dart';

abstract class VoiceNoteEvent {}

class StartListening extends VoiceNoteEvent {}

class StopListening extends VoiceNoteEvent {}

class UpdateTranscription extends VoiceNoteEvent {
  final String text;
  UpdateTranscription(this.text);
}

class SummarizeNote extends VoiceNoteEvent {}

class SaveNote extends VoiceNoteEvent {}

class ResetNote extends VoiceNoteEvent {}

What does this code do?

Each event maps to a user action on the voice note screen. StartListening and StopListening control the microphone. UpdateTranscription fires every time new words are recognized.

SummarizeNote triggers the extractive summarization. SaveNote persists the transcription and summary to the database. ResetNote clears everything for a fresh recording.

  • Save voice_note_event.dart.
  • Create voice_note_state.dart in the same voice_note/ folder and add this code:
part of 'voice_note_bloc.dart';

abstract class VoiceNoteState {}

class VoiceNoteInitial extends VoiceNoteState {}

class VoiceNoteListening extends VoiceNoteState {
  final String currentText;
  VoiceNoteListening(this.currentText);
}

class VoiceNoteTranscribed extends VoiceNoteState {
  final String text;
  VoiceNoteTranscribed(this.text);
}

class VoiceNoteSummarized extends VoiceNoteState {
  final String text;
  final String summary;
  VoiceNoteSummarized(this.text, this.summary);
}

class VoiceNoteSaved extends VoiceNoteState {}

class VoiceNoteError extends VoiceNoteState {
  final String message;
  VoiceNoteError(this.message);
}

What does this code do?

Each state represents a phase of the recording flow. VoiceNoteListening carries the live transcription text so the UI can update in real time.

VoiceNoteTranscribed means the microphone stopped and the text is ready for summarization. VoiceNoteSummarized holds both the full text and the generated summary. VoiceNoteSaved confirms the note was persisted to the database.

  • Save voice_note_state.dart.
  • Create voice_note_bloc.dart in the same voice_note/ folder and add this code:
import 'package:bloc/bloc.dart';
import '../../../data/services/speech_service.dart';
import '../../../domain/usecases/add_note.dart';
import '../../../domain/usecases/summarize_text.dart';

part 'voice_note_event.dart';
part 'voice_note_state.dart';

class VoiceNoteBloc extends Bloc<VoiceNoteEvent, VoiceNoteState> {
  final SpeechService speechService;
  final AddNote addNote;
  final SummarizeText summarizeText;

  String _currentText = '';
  String? _currentSummary;

  VoiceNoteBloc({
    required this.speechService,
    required this.addNote,
    required this.summarizeText,
  }) : super(VoiceNoteInitial()) {
    on<StartListening>(_onStartListening);
    on<StopListening>(_onStopListening);
    on<UpdateTranscription>(_onUpdateTranscription);
    on<SummarizeNote>(_onSummarizeNote);
    on<SaveNote>(_onSaveNote);
    on<ResetNote>(_onResetNote);
  }

  Future<void> _onStartListening(
      StartListening event, Emitter<VoiceNoteState> emit) async {
    final result = await speechService.startListening((text) {
      add(UpdateTranscription(text));
    });
    result.fold(
      (failure) => emit(VoiceNoteError(failure.message)),
      (_) => emit(VoiceNoteListening(_currentText)),
    );
  }

  Future<void> _onStopListening(
      StopListening event, Emitter<VoiceNoteState> emit) async {
    await speechService.stopListening();
    emit(VoiceNoteTranscribed(_currentText));
  }

  void _onUpdateTranscription(
      UpdateTranscription event, Emitter<VoiceNoteState> emit) {
    _currentText = event.text;
    emit(VoiceNoteListening(_currentText));
  }

  Future<void> _onSummarizeNote(
      SummarizeNote event, Emitter<VoiceNoteState> emit) async {
    final result = await summarizeText(_currentText);
    result.fold(
      (failure) => emit(VoiceNoteError(failure.message)),
      (summary) {
        _currentSummary = summary;
        emit(VoiceNoteSummarized(_currentText, summary));
      },
    );
  }

  Future<void> _onSaveNote(
      SaveNote event, Emitter<VoiceNoteState> emit) async {
    final result = await addNote(_currentText, _currentSummary);
    result.fold(
      (failure) => emit(VoiceNoteError(failure.message)),
      (_) => emit(VoiceNoteSaved()),
    );
  }

  void _onResetNote(ResetNote event, Emitter<VoiceNoteState> emit) {
    _currentText = '';
    _currentSummary = null;
    emit(VoiceNoteInitial());
  }
}

What does this code do?

The VoiceNoteBloc holds two private fields: _currentText and _currentSummary. These accumulate data across the recording lifecycle so each handler can access the latest transcription.

The _onStartListening handler passes a callback to SpeechService. Every time the speech recognizer detects new words, it calls add(UpdateTranscription(text)) to dispatch a new event back into the BLoC. This keeps the state reactive without tight coupling.

The _onResetNote handler clears both private fields and returns to VoiceNoteInitial, preparing the screen for a fresh recording.

  • Save voice_note_bloc.dart.
  • Check that all red underlines in the three voice_note/ files have cleared.

Errors in voice_note_bloc.dart?

If you see an error on SpeechService, check that the import path '../../../data/services/speech_service.dart' matches your folder structure exactly. Count three levels up from voice_note/ to reach notes/.

help me debug import errors in my VoiceNoteBloc

Register BLoCs in the dependency injection container

The final piece is registering both BLoCs with get_it so the UI layer can access them without manually constructing dependencies.

Why factory instead of lazySingleton?

BLoCs are registered with registerFactory instead of registerLazySingleton. A factory creates a fresh instance every time it is requested.

This is important because BLoCs hold internal state. If you reuse a single instance, old states leak across screen navigations. A factory guarantees each screen gets a clean BLoC.

  • Switch back to lib/injection_container.dart in VS Code.
  • Find the existing imports at the top of the file. Add these two new import lines below them:
import 'features/notes/presentation/bloc/note_list/note_list_bloc.dart';
import 'features/notes/presentation/bloc/voice_note/voice_note_bloc.dart';
  • Find the line Future<void> init() async { and add this BLoC registration block directly below it, before the existing // Database comment:
  // BLoCs
  sl.registerFactory(() => NoteListBloc(
        getNotes: sl<GetNotes>(),
        deleteNote: sl<DeleteNote>(),
      ));
  sl.registerFactory(() => VoiceNoteBloc(
        speechService: sl<SpeechService>(),
        addNote: sl<AddNote>(),
        summarizeText: sl<SummarizeText>(),
      ));

What does this code do?

Each registerFactory call tells get_it how to construct a fresh BLoC on demand. The sl<GetNotes>() calls resolve the use cases and services that were registered earlier in the same file.

Placing BLoC registrations at the top of init() is a convention. BLoCs depend on everything below them (use cases, services, database), and reading top-down shows the dependency chain clearly.

  • Save injection_container.dart.
  • Confirm no red underlines appear in the file. All BLoC imports and sl<> lookups should resolve without errors.

Unresolved import for NoteListBloc or VoiceNoteBloc?

Double-check that the folder names match exactly: note_list (with underscore) and voice_note (with underscore). A mismatched folder name causes the import path to break.

help me fix unresolved imports in injection_container.dart

✔️ Awesome, I've got everything!

Great. Double check you've saved all files in both note_list/ and voice_note/ folders, plus injection_container.dart.

ⓧ I'd like to double check the full code

Here is the full code for each file you created or updated in this step.

part of 'note_list_bloc.dart';

abstract class NoteListEvent {}

class LoadNotes extends NoteListEvent {}

class DeleteNoteEvent extends NoteListEvent {
  final int id;
  DeleteNoteEvent(this.id);
}
part of 'note_list_bloc.dart';

abstract class NoteListState {}

class NoteListInitial extends NoteListState {}

class NoteListLoading extends NoteListState {}

class NoteListLoaded extends NoteListState {
  final List<Note> notes;
  NoteListLoaded(this.notes);
}

class NoteListError extends NoteListState {
  final String message;
  NoteListError(this.message);
}
import 'package:bloc/bloc.dart';
import '../../../domain/entities/note.dart';
import '../../../domain/usecases/delete_note.dart';
import '../../../domain/usecases/get_notes.dart';

part 'note_list_event.dart';
part 'note_list_state.dart';

class NoteListBloc extends Bloc<NoteListEvent, NoteListState> {
  final GetNotes getNotes;
  final DeleteNote deleteNote;

  NoteListBloc({required this.getNotes, required this.deleteNote})
      : super(NoteListInitial()) {
    on<LoadNotes>(_onLoadNotes);
    on<DeleteNoteEvent>(_onDeleteNote);
  }

  Future<void> _onLoadNotes(
      LoadNotes event, Emitter<NoteListState> emit) async {
    emit(NoteListLoading());
    final result = await getNotes();
    result.fold(
      (failure) => emit(NoteListError(failure.message)),
      (notes) => emit(NoteListLoaded(notes)),
    );
  }

  Future<void> _onDeleteNote(
      DeleteNoteEvent event, Emitter<NoteListState> emit) async {
    final result = await deleteNote(event.id);
    result.fold(
      (failure) => emit(NoteListError(failure.message)),
      (_) => add(LoadNotes()),
    );
  }
}
part of 'voice_note_bloc.dart';

abstract class VoiceNoteEvent {}

class StartListening extends VoiceNoteEvent {}

class StopListening extends VoiceNoteEvent {}

class UpdateTranscription extends VoiceNoteEvent {
  final String text;
  UpdateTranscription(this.text);
}

class SummarizeNote extends VoiceNoteEvent {}

class SaveNote extends VoiceNoteEvent {}

class ResetNote extends VoiceNoteEvent {}
part of 'voice_note_bloc.dart';

abstract class VoiceNoteState {}

class VoiceNoteInitial extends VoiceNoteState {}

class VoiceNoteListening extends VoiceNoteState {
  final String currentText;
  VoiceNoteListening(this.currentText);
}

class VoiceNoteTranscribed extends VoiceNoteState {
  final String text;
  VoiceNoteTranscribed(this.text);
}

class VoiceNoteSummarized extends VoiceNoteState {
  final String text;
  final String summary;
  VoiceNoteSummarized(this.text, this.summary);
}

class VoiceNoteSaved extends VoiceNoteState {}

class VoiceNoteError extends VoiceNoteState {
  final String message;
  VoiceNoteError(this.message);
}
import 'package:bloc/bloc.dart';
import '../../../data/services/speech_service.dart';
import '../../../domain/usecases/add_note.dart';
import '../../../domain/usecases/summarize_text.dart';

part 'voice_note_event.dart';
part 'voice_note_state.dart';

class VoiceNoteBloc extends Bloc<VoiceNoteEvent, VoiceNoteState> {
  final SpeechService speechService;
  final AddNote addNote;
  final SummarizeText summarizeText;

  String _currentText = '';
  String? _currentSummary;

  VoiceNoteBloc({
    required this.speechService,
    required this.addNote,
    required this.summarizeText,
  }) : super(VoiceNoteInitial()) {
    on<StartListening>(_onStartListening);
    on<StopListening>(_onStopListening);
    on<UpdateTranscription>(_onUpdateTranscription);
    on<SummarizeNote>(_onSummarizeNote);
    on<SaveNote>(_onSaveNote);
    on<ResetNote>(_onResetNote);
  }

  Future<void> _onStartListening(
      StartListening event, Emitter<VoiceNoteState> emit) async {
    final result = await speechService.startListening((text) {
      add(UpdateTranscription(text));
    });
    result.fold(
      (failure) => emit(VoiceNoteError(failure.message)),
      (_) => emit(VoiceNoteListening(_currentText)),
    );
  }

  Future<void> _onStopListening(
      StopListening event, Emitter<VoiceNoteState> emit) async {
    await speechService.stopListening();
    emit(VoiceNoteTranscribed(_currentText));
  }

  void _onUpdateTranscription(
      UpdateTranscription event, Emitter<VoiceNoteState> emit) {
    _currentText = event.text;
    emit(VoiceNoteListening(_currentText));
  }

  Future<void> _onSummarizeNote(
      SummarizeNote event, Emitter<VoiceNoteState> emit) async {
    final result = await summarizeText(_currentText);
    result.fold(
      (failure) => emit(VoiceNoteError(failure.message)),
      (summary) {
        _currentSummary = summary;
        emit(VoiceNoteSummarized(_currentText, summary));
      },
    );
  }

  Future<void> _onSaveNote(
      SaveNote event, Emitter<VoiceNoteState> emit) async {
    final result = await addNote(_currentText, _currentSummary);
    result.fold(
      (failure) => emit(VoiceNoteError(failure.message)),
      (_) => emit(VoiceNoteSaved()),
    );
  }

  void _onResetNote(ResetNote event, Emitter<VoiceNoteState> emit) {
    _currentText = '';
    _currentSummary = null;
    emit(VoiceNoteInitial());
  }
}
import 'package:get_it/get_it.dart';
import 'features/notes/data/datasources/app_database.dart';
import 'features/notes/data/repositories/note_repository_impl.dart';
import 'features/notes/data/services/speech_service.dart';
import 'features/notes/data/services/summarization_service.dart';
import 'features/notes/domain/repositories/note_repository.dart';
import 'features/notes/domain/usecases/add_note.dart';
import 'features/notes/domain/usecases/delete_note.dart';
import 'features/notes/domain/usecases/get_notes.dart';
import 'features/notes/domain/usecases/summarize_text.dart';
import 'features/notes/presentation/bloc/note_list/note_list_bloc.dart';
import 'features/notes/presentation/bloc/voice_note/voice_note_bloc.dart';

final sl = GetIt.instance;

Future<void> init() async {
  // BLoCs
  sl.registerFactory(() => NoteListBloc(
        getNotes: sl<GetNotes>(),
        deleteNote: sl<DeleteNote>(),
      ));
  sl.registerFactory(() => VoiceNoteBloc(
        speechService: sl<SpeechService>(),
        addNote: sl<AddNote>(),
        summarizeText: sl<SummarizeText>(),
      ));

  // Database
  sl.registerLazySingleton<AppDatabase>(() => AppDatabase());
  sl.registerLazySingleton<NoteDao>(() => sl<AppDatabase>().noteDao);

  // Repository
  sl.registerLazySingleton<NoteRepository>(
    () => NoteRepositoryImpl(sl<NoteDao>()),
  );

  // Use cases
  sl.registerLazySingleton(() => GetNotes(sl<NoteRepository>()));
  sl.registerLazySingleton(() => AddNote(sl<NoteRepository>()));
  sl.registerLazySingleton(() => DeleteNote(sl<NoteRepository>()));
  sl.registerLazySingleton(() => SummarizeText(sl<NoteRepository>()));

  // Services
  sl.registerLazySingleton(() => SpeechService());
  sl.registerLazySingleton(() => SummarizationService());
}

Your state management layer is complete. Both BLoCs know how to process user actions into UI states, and the DI container can supply fresh instances on demand. Next up, you'll build the actual screens that use these BLoCs to display notes and record voice.

Build the Presentation Layer

Your domain layer defines what the app does. Your data layer handles how it stores and fetches information. Your BLoC layer manages state transitions. Now it is time to build the screens that let users actually interact with all of that logic.

In this step, you will create two pages: a note list screen showing all saved notes, and a voice recording screen where users speak, see their transcription in real-time, summarize it, and save it.

In this step, get ready to:
  • Create the notes list screen with delete functionality.
  • Build the voice recording screen with transcription display.
  • Wire up main.dart with dependency injection and routing.
Create the NoteListPage

The notes list screen is the home screen of Listy. It uses a BlocBuilder to rebuild the list every time the note state changes (loading, loaded, or error).

  • Create a new file at lib/features/notes/presentation/pages/note_list_page.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/note.dart';
import '../bloc/note_list/note_list_bloc.dart';
import 'voice_note_page.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Listy')),
      body: BlocBuilder<NoteListBloc, NoteListState>(
        builder: (context, state) {
          if (state is NoteListLoading) {
            return const Center(child: CircularProgressIndicator());
          } else if (state is NoteListLoaded) {
            if (state.notes.isEmpty) {
              return const Center(child: Text('No notes yet. Tap + to add one!'));
            }
            return ListView.builder(
              itemCount: state.notes.length,
              itemBuilder: (context, index) {
                final note = state.notes[index];
                // Extract title from first line of content
                final title = note.content.split('\n').first;
                return ListTile(
                  title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
                  subtitle: note.summary != null
                      ? Text(note.summary!, maxLines: 2, overflow: TextOverflow.ellipsis)
                      : Text(note.createdAt.toLocal().toString()),
                  trailing: IconButton(
                    icon: const Icon(Icons.delete),
                    onPressed: () =>
                        context.read<NoteListBloc>().add(DeleteNoteEvent(note.id)),
                  ),
                );
              },
            );
          } else if (state is NoteListError) {
            return Center(child: Text('Error: ${state.message}'));
          }
          return const SizedBox.shrink();
        },
      ),
      // FAB navigates to VoiceNotePage, reloads notes on return
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await Navigator.push(
            context,
            MaterialPageRoute(builder: (_) => const VoiceNotePage()),
          );
          if (context.mounted) {
            context.read<NoteListBloc>().add(LoadNotes());
          }
        },
        child: const Icon(Icons.mic),
      ),
    );
  }
}

What does this code do?

BlocBuilder<NoteListBloc, NoteListState> rebuilds the body widget every time the note list state changes. It checks which state class is active and renders the appropriate UI (loading spinner, list of notes, error message, or empty state).

The FloatingActionButton uses Navigator.push to open the voice note screen. When the user returns, it dispatches LoadNotes() to refresh the list with any newly saved notes.

  • Save note_list_page.dart.

You will not see a change yet. The next substep wires this page into the app.

✔️ Awesome, I've got everything!

Great. Double check you have saved note_list_page.dart 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 '../../domain/entities/note.dart';
import '../bloc/note_list/note_list_bloc.dart';
import 'voice_note_page.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Listy')),
      body: BlocBuilder<NoteListBloc, NoteListState>(
        builder: (context, state) {
          if (state is NoteListLoading) {
            return const Center(child: CircularProgressIndicator());
          } else if (state is NoteListLoaded) {
            if (state.notes.isEmpty) {
              return const Center(child: Text('No notes yet. Tap + to add one!'));
            }
            return ListView.builder(
              itemCount: state.notes.length,
              itemBuilder: (context, index) {
                final note = state.notes[index];
                final title = note.content.split('\n').first;
                return ListTile(
                  title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
                  subtitle: note.summary != null
                      ? Text(note.summary!, maxLines: 2, overflow: TextOverflow.ellipsis)
                      : Text(note.createdAt.toLocal().toString()),
                  trailing: IconButton(
                    icon: const Icon(Icons.delete),
                    onPressed: () =>
                        context.read<NoteListBloc>().add(DeleteNoteEvent(note.id)),
                  ),
                );
              },
            );
          } else if (state is NoteListError) {
            return Center(child: Text('Error: ${state.message}'));
          }
          return const SizedBox.shrink();
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await Navigator.push(
            context,
            MaterialPageRoute(builder: (_) => const VoiceNotePage()),
          );
          if (context.mounted) {
            context.read<NoteListBloc>().add(LoadNotes());
          }
        },
        child: const Icon(Icons.mic),
      ),
    );
  }
}
Create the VoiceNotePage

The voice note screen needs to do two things simultaneously: rebuild the UI as the transcription updates, and trigger one-off side effects (show a snackbar, pop the screen) when the note saves. A BlocConsumer handles both in a single widget.

  • Create a new file at lib/features/notes/presentation/pages/voice_note_page.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../injection_container.dart';
import '../bloc/voice_note/voice_note_bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    // Provide a fresh VoiceNoteBloc from get_it for this screen
    return BlocProvider(
      create: (_) => sl<VoiceNoteBloc>(),
      child: const _VoiceNoteView(),
    );
  }
}

class _VoiceNoteView extends StatelessWidget {
  const _VoiceNoteView();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('New Voice Note')),
      body: BlocConsumer<VoiceNoteBloc, VoiceNoteState>(
        // Listener handles one-off side effects
        listener: (context, state) {
          if (state is VoiceNoteSaved) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Note saved!')),
            );
            Navigator.pop(context);
          } else if (state is VoiceNoteError) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Error: ${state.message}')),
            );
          }
        },
        // Builder rebuilds the UI on every state change
        builder: (context, state) {
          final isListening = state is VoiceNoteListening;
          final transcription = state is VoiceNoteListening
              ? state.currentText
              : state is VoiceNoteTranscribed
                  ? state.text
                  : state is VoiceNoteSummarized
                      ? state.text
                      : '';
          final summary =
              state is VoiceNoteSummarized ? state.summary : null;

          return Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                // Transcription display area
                Expanded(
                  child: Container(
                    width: double.infinity,
                    padding: const EdgeInsets.all(12),
                    decoration: BoxDecoration(
                      border: Border.all(color: Colors.grey),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Text(
                      transcription.isEmpty
                          ? 'Tap the mic to start recording...'
                          : transcription,
                    ),
                  ),
                ),
                // Summary box appears after summarization
                if (summary != null) ...
                  [
                    const SizedBox(height: 8),
                    Container(
                      width: double.infinity,
                      padding: const EdgeInsets.all(12),
                      decoration: BoxDecoration(
                        color: Colors.blue.shade50,
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text('Summary: $summary'),
                    ),
                  ],
                const SizedBox(height: 16),
                // Action buttons row
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    FloatingActionButton(
                      heroTag: 'mic',
                      onPressed: () {
                        if (isListening) {
                          context.read<VoiceNoteBloc>().add(StopListening());
                        } else {
                          context.read<VoiceNoteBloc>().add(StartListening());
                        }
                      },
                      backgroundColor: isListening ? Colors.red : Colors.blue,
                      child: Icon(isListening ? Icons.stop : Icons.mic),
                    ),
                    ElevatedButton(
                      onPressed: transcription.isNotEmpty
                          ? () => context
                              .read<VoiceNoteBloc>()
                              .add(SummarizeNote())
                          : null,
                      child: const Text('Summarize'),
                    ),
                    ElevatedButton(
                      onPressed: transcription.isNotEmpty
                          ? () =>
                              context.read<VoiceNoteBloc>().add(SaveNote())
                          : null,
                      child: const Text('Save'),
                    ),
                  ],
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

What does this code do?

The VoiceNotePage wrapper creates a fresh VoiceNoteBloc from the get_it service locator (sl) and provides it to the widget subtree via BlocProvider. Each time this page opens, the user gets a clean recording session.

The BlocConsumer splits responsibilities: the listener fires once per state change for side effects (snackbar on save, snackbar on error, then pops the screen). The builder rebuilds the UI every time, reading the current transcription text and summary from whichever state is active.

The microphone FAB toggles between red (stop) and blue (record). The Summarize and Save buttons are disabled until transcription text exists.

  • Save voice_note_page.dart.

You will not see a change yet. The next substep connects both pages to the app entry point.

Seeing a red underline on the spread operator?

The if (summary != null) ...[] syntax is a Dart collection-if with a spread. Make sure you have the three dots ... directly before the opening bracket [ with no space between.

help me fix a Dart collection-if spread syntax error in my VoiceNotePage

✔️ Awesome, I've got everything!

Great. Double check you have saved voice_note_page.dart 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 '../../../../injection_container.dart';
import '../bloc/voice_note/voice_note_bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => sl<VoiceNoteBloc>(),
      child: const _VoiceNoteView(),
    );
  }
}

class _VoiceNoteView extends StatelessWidget {
  const _VoiceNoteView();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('New Voice Note')),
      body: BlocConsumer<VoiceNoteBloc, VoiceNoteState>(
        listener: (context, state) {
          if (state is VoiceNoteSaved) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Note saved!')),
            );
            Navigator.pop(context);
          } else if (state is VoiceNoteError) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Error: ${state.message}')),
            );
          }
        },
        builder: (context, state) {
          final isListening = state is VoiceNoteListening;
          final transcription = state is VoiceNoteListening
              ? state.currentText
              : state is VoiceNoteTranscribed
                  ? state.text
                  : state is VoiceNoteSummarized
                      ? state.text
                      : '';
          final summary =
              state is VoiceNoteSummarized ? state.summary : null;

          return Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                Expanded(
                  child: Container(
                    width: double.infinity,
                    padding: const EdgeInsets.all(12),
                    decoration: BoxDecoration(
                      border: Border.all(color: Colors.grey),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Text(
                      transcription.isEmpty
                          ? 'Tap the mic to start recording...'
                          : transcription,
                    ),
                  ),
                ),
                if (summary != null) ...
                  [
                    const SizedBox(height: 8),
                    Container(
                      width: double.infinity,
                      padding: const EdgeInsets.all(12),
                      decoration: BoxDecoration(
                        color: Colors.blue.shade50,
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text('Summary: $summary'),
                    ),
                  ],
                const SizedBox(height: 16),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    FloatingActionButton(
                      heroTag: 'mic',
                      onPressed: () {
                        if (isListening) {
                          context.read<VoiceNoteBloc>().add(StopListening());
                        } else {
                          context.read<VoiceNoteBloc>().add(StartListening());
                        }
                      },
                      backgroundColor: isListening ? Colors.red : Colors.blue,
                      child: Icon(isListening ? Icons.stop : Icons.mic),
                    ),
                    ElevatedButton(
                      onPressed: transcription.isNotEmpty
                          ? () => context
                              .read<VoiceNoteBloc>()
                              .add(SummarizeNote())
                          : null,
                      child: const Text('Summarize'),
                    ),
                    ElevatedButton(
                      onPressed: transcription.isNotEmpty
                          ? () =>
                              context.read<VoiceNoteBloc>().add(SaveNote())
                          : null,
                      child: const Text('Save'),
                    ),
                  ],
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}
Set up main.dart with MultiBlocProvider and routing

The app entry point needs to initialize the dependency injection container before running, then wrap the widget tree in a MultiBlocProvider so that the NoteListBloc is available to the home screen from the moment it loads.

  • Open lib/main.dart and replace its entire contents with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'injection_container.dart' as di;
import 'features/notes/presentation/bloc/note_list/note_list_bloc.dart';
import 'features/notes/presentation/pages/note_list_page.dart';

void main() async {
  // Ensure Flutter bindings are ready before async init
  WidgetsFlutterBinding.ensureInitialized();
  // Initialize all get_it registrations
  await di.init();
  runApp(const ListyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      // Provide NoteListBloc at the top of the widget tree
      providers: [
        BlocProvider(
          create: (_) => di.sl<NoteListBloc>()..add(LoadNotes()),
        ),
      ],
      child: MaterialApp(
        title: 'Listy',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          useMaterial3: true,
        ),
        home: const NoteListPage(),
      ),
    );
  }
}

What does this code do?

WidgetsFlutterBinding.ensureInitialized() is required before any async work in main(). It guarantees Flutter's engine is ready before the DI container initializes the database.

MultiBlocProvider wraps the MaterialApp so that any widget in the tree can access NoteListBloc. The cascade operator ..add(LoadNotes()) immediately dispatches the load event, so notes appear as soon as the home screen renders.

  • Save main.dart.
  • Connect your Android device via USB and run the app by executing this command in your terminal:
flutter run
  • You should see the Listy home screen on your device with the text "No notes yet. Tap + to add one!" in the center and a blue microphone button in the bottom-right corner.

App not launching on your device?

Make sure USB debugging is enabled on your Redmi device. Go to Settings > Additional settings > Developer options and toggle USB debugging on.

If you see a build error mentioning unresolved imports, double check that the file paths for note_list_page.dart and voice_note_page.dart match the folder structure exactly. The pages/ folder must exist inside presentation/.

help me debug why my Flutter app is not launching on my Android device

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'injection_container.dart' as di;
import 'features/notes/presentation/bloc/note_list/note_list_bloc.dart';
import 'features/notes/presentation/pages/note_list_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await di.init();
  runApp(const ListyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(
          create: (_) => di.sl<NoteListBloc>()..add(LoadNotes()),
        ),
      ],
      child: MaterialApp(
        title: 'Listy',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          useMaterial3: true,
        ),
        home: const NoteListPage(),
      ),
    );
  }
}

Your presentation layer is complete. Listy now has a full UI connected to your BLoC state management, with real-time transcription and one-tap summarization ready to use on your device.

Secret mission

Add Search Functionality

Your app saves notes, but can you find them again? Add a search bar that filters notes by keyword in real time, complete with debouncing to keep the UI smooth.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine and Android device. There are no ongoing cloud costs or subscriptions to worry about.

Decide whether to keep your resources as they are, or delete them entirely.

Resources you used:

  • The listy Flutter project folder on your Desktop.
  • The Listy app installed on your Android device (includes the local SQLite database listy_db.sqlite).

Keep everything running

No action needed. Choose this if you plan to keep developing Listy or want to reference the code later.

  • The project folder stays on your Desktop at no cost.
  • The Listy app stays installed on your Android device. The local database persists indefinitely with no storage fees.

Pause - I'll come back to this later

Since everything is local and free, there is nothing to pause. Your project folder and installed app consume minimal disk space and no ongoing resources.

  • If you have a Flutter development server still running in your terminal, press q to quit it.
  • Your project and app will be exactly as you left them when you return.

Delete - I don't want to use this again

Remove all project files from your computer and uninstall the app from your device.

  • Delete the project folder from your Desktop by running this command (macOS/Linux):
cd ~/Desktop
rm -rf listy

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q listy
  • Uninstall Listy from your Android device. Open Settings on your device, tap Apps, find Listy in the list, then tap Uninstall. This removes the app and its local SQLite database from your device.

That's a Wrap!

That's a Wrap!

Nice work! You just built Listy, a fully offline voice note-taking app with real-time speech transcription, text summarization, and local persistence.

You've learned how to:

  • Architect a Flutter app using Clean Architecture with proper separation of data, domain, and presentation layers.
  • Use the BLoC pattern for state management, get_it for dependency injection, and dartz Either for functional error handling across the entire app.
  • Set up a Drift SQLite database with code generation for type-safe local persistence, and integrate speech-to-text with extractive text summarization using pure Dart.
  • Secret Mission: Added keyword search functionality with reactive filtering and debouncing.

Ready to quiz yourself?