Offline-First Field Inspection App

Build an enterprise Flutter app with offline sync, BLoC, and Supabase backend.

Introduction

30 Second Summary

Construction inspectors work in basements, tunnels, and remote job sites where cell service doesn't exist. Their data still needs to be captured accurately, synced later, and trusted by teams back at the office.

In this project, you will build a production-quality offline-first field inspection app with Flutter and Supabase that captures inspection data, photos, GPS coordinates, QR codes, and digital signatures locally, then syncs everything to the cloud when connectivity returns.

What You'll Build

A full-featured Android inspection app using feature-first Clean Architecture with the BLoC pattern, backed by a local SQLite database and a Supabase cloud backend with auth, storage, and real-time sync.

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

  • A feature-first Clean Architecture codebase with 7 features, each with data/domain/presentation layers and BLoC state management.
  • A working offline sync engine that queues writes locally and syncs to Supabase with conflict resolution, photo upload, and exponential backoff retry.
  • Four hardware integrations for camera, GPS, QR scanner, and digital signature capture, plus professional PDF report generation.
  • Secret Mission: Add an interactive statistics dashboard with pie charts and bar charts using fl_chart.

Are there any prerequisites?

This is an advanced project. You should have basic Flutter knowledge (widgets, StatefulWidget, navigation) and familiarity with Dart syntax and async/await.

No prior experience with Supabase, BLoC, or offline architecture is needed. The project teaches all of these from scratch.

Set Up Your Development Environment

Before you can build an offline-first inspection app, you need a complete Flutter development environment.

This step installs all the tools you need: Git for version control, VS Code as your editor, the Flutter SDK for building the app, Android Studio for the emulator, and cloud accounts for your backend and crash reporting.

In this step, get ready to:
  • Install Git, VS Code, and the Flutter SDK.
  • Set up Android Studio with an emulator.
  • Create Supabase and Sentry accounts.
Install Git, VS Code, and the Flutter SDK

Git tracks your code changes and is also required by the Flutter SDK installer. VS Code is the editor you will use for the entire project.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Check if Git is already installed by running this command:
git --version

✔️ I see a version number

Git is ready to go. Move on to installing VS Code below.

ⓧ Command not found

  • Install Git for Windows by running this command:
winget install --id Git.Git -e --source winget

What does this command do?

This uses Windows Package Manager (winget) to install Git. The --id flag specifies the exact package, and -e ensures an exact match so you get the right one.

  • Close and reopen PowerShell after the install finishes.
  • Verify by running git --version again. You should see a version number like 2.54.0.

Still not recognized?

Make sure you fully closed and reopened PowerShell after installation. The winget installer adds Git to your PATH, but existing terminals do not pick up the change until they restart.

help me troubleshoot Git installation on Windows

ⓧ winget is not recognized

Your Windows version may not include winget by default. Install it from the Microsoft Store by searching for App Installer.

  • Open the Microsoft Store app.
  • Search for App Installer and install (or update) it.
  • Close and reopen PowerShell, then retry the winget install --id Git.Git -e --source winget command.

Now install VS Code if you do not already have it.

  • Open your browser and go to code.visualstudio.com.
  • Download and run the installer for Windows.
  • Accept the defaults and complete the installation.
  • Press the Windows key, type Visual Studio Code, and press Enter to open it.

Next, add the Flutter and Dart extensions.

  • In VS Code, click the Extensions icon in the left sidebar (or press Ctrl+Shift+X).
  • Search for Flutter and click Install on the one published by Dart Code.
  • Installing the Flutter extension automatically installs the Dart extension too.

Why install via VS Code?

The Flutter extension for VS Code can download the Flutter SDK for you. This is the quickest setup path recommended by the Flutter team.

Now check if the Flutter SDK is already on your machine.

  • Open a new terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
  • Check your Flutter version by running:
flutter --version

✔️ I see version 3.41.5 or higher

Your Flutter SDK is up to date. Skip ahead to the Android Studio substep below.

ⓧ I see an older version

You have Flutter installed but need to upgrade to 3.41.5.

  • Upgrade Flutter by running this command:
flutter upgrade
  • After the upgrade completes, run flutter --version again. You should see 3.41.5 or higher.

Upgrade stuck or failing?

Make sure you are on the stable channel. Run flutter channel stable first, then retry flutter upgrade.

help me fix a Flutter upgrade that is stuck or failing

ⓧ Command not found

Flutter is not installed yet. The VS Code Flutter extension will download it for you.

  • In VS Code, open the Command Palette by pressing Ctrl+Shift+P (macOS: Cmd+Shift+P).
  • Type flutter and select Flutter: New Project.
  • VS Code prompts you to locate the Flutter SDK. Click Download SDK.
  • Choose a folder to install Flutter into (e.g. C:\dev).
  • Click Clone Flutter. This download takes a few minutes.
  • When prompted, click Add SDK to PATH.
  • Close and reopen VS Code so the new PATH takes effect.
  • Open a new terminal in VS Code (Ctrl+`) and run flutter --version. You should see 3.41.5 or higher.

flutter still not recognized after restarting VS Code?

Your system PATH may not have updated. Try closing ALL terminal windows and VS Code, then reopening. On Windows, you may need to log out and back in for system-wide PATH changes to apply.

help me add Flutter to my PATH on Windows

What is Flutter?

Flutter is Google's UI toolkit for building natively compiled apps for mobile, web, and desktop from a single Dart codebase. In this project, you will target Android.

Set up Android Studio and create an emulator

You need Android Studio for the Android SDK and a virtual device to test your app. You will still write code in VS Code.

  • Open your browser and go to developer.android.com/studio.
  • Download the installer for Windows.
  • Run the installer and accept the defaults.
  • When Android Studio opens for the first time, it launches a setup wizard. Follow the prompts and accept the default component selections.

Now make sure the required SDK components are installed.

  • In Android Studio, click More Actions (or the three-dot menu) on the welcome screen, then click SDK Manager.
  • Under the SDK Platforms tab, check Android API 35.
  • Switch to the SDK Tools tab.
  • Ensure these are checked: Android SDK Command-line Tools, Android SDK Build-Tools, Android SDK Platform-Tools, and Android Emulator.
  • Click Apply and wait for the downloads to complete, then click OK.

Why Android API 35?

API 35 is the latest stable Android platform. Several plugins in this project (like geolocator) require a compile SDK of 35 for proper permissions handling.

Next, create a virtual device (emulator) to run your app.

  • On the Android Studio welcome screen, click More Actions, then Virtual Device Manager.
  • Click Create Virtual Device.
  • Select Pixel 7 from the phone list and click Next.
  • Select the system image for API 35. If it shows a download icon, click it to download first.
  • Click Next, then Finish.

You should see your new Pixel 7 device listed in the Virtual Device Manager.

Finally, accept the Android SDK licenses so Flutter can use the SDK.

  • Back in VS Code's terminal, accept all Android licenses by running:
flutter doctor --android-licenses
  • The command will prompt you multiple times. Type y and press Enter for each prompt to accept all licenses.

Seeing a Java or sdkmanager error?

This usually means the Android SDK Command-line Tools are not installed. Go back to Android Studio's SDK Manager, confirm Android SDK Command-line Tools is checked under the SDK Tools tab, and apply.

help me fix flutter doctor --android-licenses errors

Create Supabase and Sentry accounts

Your app needs a cloud backend for authentication, data storage, and file uploads. It also needs crash reporting to catch errors in production.

  • Open your browser and go to supabase.com.
  • Click Start your project and sign up for a free account (GitHub login is quickest).
  • Once logged in, click New Project.
  • Enter field-inspector as the project name.
  • Set a strong database password (save it somewhere safe).
  • Choose the region nearest to you for lowest latency.
  • Click Create new project. Provisioning takes about a minute.

What is Supabase?

Supabase is an open-source backend-as-a-service built on PostgreSQL. It provides authentication, a Postgres database, file storage, and real-time subscriptions out of the box. The free tier includes 500 MB of database storage and 1 GB of file storage.

Now copy the credentials your Flutter app needs to connect.

  • In your Supabase project dashboard, click Settings in the left sidebar, then API.
  • Copy the Project URL and save it in a temporary text file.
  • Copy the anon (publishable) key under Project API keys and save it alongside the URL.

The anon key is safe to include in your app

Unlike secret keys, the publishable anon key is designed to be embedded in client-side code. Row Level Security policies (which you will set up in the next step) protect your data.

Next, set up Sentry for crash reporting.

  • Open your browser and go to sentry.io.
  • Sign up for a free account.
  • When prompted to create a project, select Flutter as the platform.
  • Give it a name (e.g. field-inspector) and click Create Project.
  • Copy the DSN shown in the setup instructions and save it in your text file alongside the Supabase credentials.

What is a DSN?

A DSN (Data Source Name) is the address Sentry uses to route crash reports from your app to the correct project. The free tier covers 5,000 error events per month.

Finally, confirm everything is wired up correctly.

  • In your VS Code terminal, run the full diagnostic:
flutter doctor -v
  • You should see green checkmarks next to Flutter, Android toolchain, and VS Code. If any show issues, follow the instructions Flutter prints to resolve them.

Seeing issues in flutter doctor output?

The most common issues are: missing Android licenses (re-run flutter doctor --android-licenses), Android SDK not found (check that Android Studio's SDK path is set), or VS Code extensions not detected (restart VS Code).

help me resolve flutter doctor warnings

Your development environment is fully configured. Next up, you will create the Supabase database schema and security policies that protect your inspection data.

Set Up Supabase Schema and Security

Your development environment is ready. Now you need a database that can store inspections, checklist items, photos, and signatures.

In this step, you will create the full Supabase database schema using SQL, lock it down with Row Level Security so each user can only access their own data, and configure Storage buckets for photo and signature uploads.

In this step, get ready to:
  • Create four Postgres tables in the Supabase SQL Editor.
  • Enable Row Level Security and create access policies.
  • Create Storage buckets and enable Realtime on the inspections table.
Create the database tables

Your app needs four tables that map to the inspection domain: an inspection record, its checklist items, photos attached to items, and signatures for sign-off.

  • Open the Supabase dashboard at supabase.com/dashboard and select your field-inspector project.
  • Click SQL Editor in the left sidebar.
  • Paste the following SQL to create the inspections table and run it by clicking Run:
-- Create the inspections table
create table public.inspections (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  title text not null,
  location_name text,
  latitude float8,
  longitude float8,
  category text,
  status text default 'draft',
  asset_id text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

What does this SQL do?

This creates the main inspections table where each row is one field inspection. The user_id column links the inspection to a Supabase Auth user, which is how RLS will filter data per user later.

The status column defaults to 'draft' so new inspections start in draft state. The asset_id will store a QR/barcode value when the inspector scans equipment.

  • Clear the SQL Editor, then paste and run the following to create the inspection_items table:
-- Create the inspection items (checklist) table
create table public.inspection_items (
  id uuid primary key default gen_random_uuid(),
  inspection_id uuid references public.inspections not null,
  description text not null,
  severity text default 'low',
  is_completed boolean default false,
  notes text,
  created_at timestamptz default now()
);
  • Clear the editor, then paste and run the following to create the inspection_photos table:
-- Create the inspection photos table
create table public.inspection_photos (
  id uuid primary key default gen_random_uuid(),
  inspection_item_id uuid references public.inspection_items not null,
  storage_path text not null,
  caption text,
  created_at timestamptz default now()
);
  • Clear the editor, then paste and run the following to create the inspection_signatures table:
-- Create the inspection signatures table
create table public.inspection_signatures (
  id uuid primary key default gen_random_uuid(),
  inspection_id uuid references public.inspections not null,
  storage_path text not null,
  signer_name text not null,
  created_at timestamptz default now()
);

How do these tables relate?

The tables form a hierarchy: inspections is the parent. Each inspection has many inspection_items (the checklist). Each item can have many inspection_photos. Signatures attach directly to the inspection for sign-off.

Foreign keys enforce these relationships. If you try to insert a photo referencing a non-existent item, Postgres will reject it.

  • Verify your tables exist by clicking Table Editor in the left sidebar.
  • You should see all four tables listed: inspections, inspection_items, inspection_photos, and inspection_signatures.

Don't see all four tables?

Make sure you ran each SQL block separately and saw a green "Success" message after each one. If you see a syntax error, check that you copied the entire block including the closing parenthesis and semicolon.

help me debug my SQL table creation error in Supabase

Enable Row Level Security and create policies

Without RLS, any authenticated user could read every other user's inspections through the Supabase Data API. RLS locks each row to the user who owns it.

  • Go back to SQL Editor in the left sidebar.
  • Enable RLS on all four tables and create policies for the inspections table by pasting and running:
-- Enable RLS on all tables
alter table public.inspections enable row level security;
alter table public.inspection_items enable row level security;
alter table public.inspection_photos enable row level security;
alter table public.inspection_signatures enable row level security;

-- Policies for inspections: users can only access their own rows
create policy "Users can select own inspections"
  on public.inspections for select
  to authenticated
  using (user_id = auth.uid());

create policy "Users can insert own inspections"
  on public.inspections for insert
  to authenticated
  with check (user_id = auth.uid());

create policy "Users can update own inspections"
  on public.inspections for update
  to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

create policy "Users can delete own inspections"
  on public.inspections for delete
  to authenticated
  using (user_id = auth.uid());

What is auth.uid()?

Supabase provides a built-in auth.uid() function that returns the UUID of the currently authenticated user from their JWT token. Every policy checks user_id = auth.uid() to ensure a user can only touch their own rows.

  • Now create cascading policies for the child tables by pasting and running:
-- Policies for inspection_items: cascade through inspection ownership
create policy "Users can select own items"
  on public.inspection_items for select
  to authenticated
  using (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

create policy "Users can insert own items"
  on public.inspection_items for insert
  to authenticated
  with check (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

create policy "Users can update own items"
  on public.inspection_items for update
  to authenticated
  using (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ))
  with check (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

create policy "Users can delete own items"
  on public.inspection_items for delete
  to authenticated
  using (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

-- Policies for inspection_photos: cascade through item -> inspection ownership
create policy "Users can select own photos"
  on public.inspection_photos for select
  to authenticated
  using (inspection_item_id in (
    select id from public.inspection_items where inspection_id in (
      select id from public.inspections where user_id = auth.uid()
    )
  ));

create policy "Users can insert own photos"
  on public.inspection_photos for insert
  to authenticated
  with check (inspection_item_id in (
    select id from public.inspection_items where inspection_id in (
      select id from public.inspections where user_id = auth.uid()
    )
  ));

create policy "Users can update own photos"
  on public.inspection_photos for update
  to authenticated
  using (inspection_item_id in (
    select id from public.inspection_items where inspection_id in (
      select id from public.inspections where user_id = auth.uid()
    )
  ))
  with check (inspection_item_id in (
    select id from public.inspection_items where inspection_id in (
      select id from public.inspections where user_id = auth.uid()
    )
  ));

create policy "Users can delete own photos"
  on public.inspection_photos for delete
  to authenticated
  using (inspection_item_id in (
    select id from public.inspection_items where inspection_id in (
      select id from public.inspections where user_id = auth.uid()
    )
  ));

-- Policies for inspection_signatures: cascade through inspection ownership
create policy "Users can select own signatures"
  on public.inspection_signatures for select
  to authenticated
  using (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

create policy "Users can insert own signatures"
  on public.inspection_signatures for insert
  to authenticated
  with check (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

create policy "Users can update own signatures"
  on public.inspection_signatures for update
  to authenticated
  using (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ))
  with check (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

create policy "Users can delete own signatures"
  on public.inspection_signatures for delete
  to authenticated
  using (inspection_id in (
    select id from public.inspections where user_id = auth.uid()
  ));

Why cascading policies?

Child tables like inspection_items do not have a direct user_id column. Instead, the policy checks whether the parent inspection_id belongs to the current user via a subquery. This keeps the schema normalized while still enforcing per-user data isolation.

  • Grant Data API privileges so the Supabase client library can access these tables. Paste and run:
-- Grant Data API privileges
grant select, insert, update, delete on public.inspections to authenticated;
grant select, insert, update, delete on public.inspection_items to authenticated;
grant select, insert, update, delete on public.inspection_photos to authenticated;
grant select, insert, update, delete on public.inspection_signatures to authenticated;

grant select, insert, update, delete on public.inspections to service_role;
grant select, insert, update, delete on public.inspection_items to service_role;
grant select, insert, update, delete on public.inspection_photos to service_role;
grant select, insert, update, delete on public.inspection_signatures to service_role;

Why are grants needed?

As of May 2026, Supabase no longer automatically exposes new tables to the Data API. Without explicit GRANT statements, the supabase_flutter client library cannot read or write your tables. RLS still controls which specific rows are accessible.

  • You should see a green "Success" notification after running the grants.

Seeing a permission error?

Make sure you are running the SQL as the default postgres role (the default in the SQL Editor). If you previously switched to a different role via the role selector dropdown at the top of the editor, switch it back.

help me fix a permission error when granting Data API privileges in Supabase

Create Storage buckets and enable Realtime

Photos and signatures are binary files that belong in object storage, not in database columns. You will create two private buckets with RLS policies that restrict uploads to paths scoped by user ID.

  • Click Storage in the left sidebar of the Supabase dashboard.
  • Click New Bucket.
  • Enter inspection-photos as the bucket name.
  • Leave the bucket as private (do not toggle Public).
  • Click Create Bucket.
  • Repeat the process to create a second bucket named inspection-signatures.

Why private buckets?

Private buckets require an authenticated JWT for every download. This means inspection photos and signatures are never publicly accessible via URL. Your RLS policies will control who can upload and download files.

  • Now create RLS policies for the Storage buckets. Go back to SQL Editor and run:
-- Storage policies for inspection-photos bucket
create policy "Users can upload own photos"
  on storage.objects for insert
  to authenticated
  with check (
    bucket_id = 'inspection-photos' and
    (storage.foldername(name))[1] = (select auth.uid()::text)
  );

create policy "Users can view own photos"
  on storage.objects for select
  to authenticated
  using (
    bucket_id = 'inspection-photos' and
    (storage.foldername(name))[1] = (select auth.uid()::text)
  );

create policy "Users can delete own photos"
  on storage.objects for delete
  to authenticated
  using (
    bucket_id = 'inspection-photos' and
    (storage.foldername(name))[1] = (select auth.uid()::text)
  );

-- Storage policies for inspection-signatures bucket
create policy "Users can upload own signatures"
  on storage.objects for insert
  to authenticated
  with check (
    bucket_id = 'inspection-signatures' and
    (storage.foldername(name))[1] = (select auth.uid()::text)
  );

create policy "Users can view own signatures"
  on storage.objects for select
  to authenticated
  using (
    bucket_id = 'inspection-signatures' and
    (storage.foldername(name))[1] = (select auth.uid()::text)
  );

create policy "Users can delete own signatures"
  on storage.objects for delete
  to authenticated
  using (
    bucket_id = 'inspection-signatures' and
    (storage.foldername(name))[1] = (select auth.uid()::text)
  );

How does the path pattern work?

The storage.foldername(name) function returns the folder segments of the file path. The policy checks that the first folder is the user's UUID. When your app uploads a photo, it will use the path {user_id}/{photo_id}.jpg. This ensures users can only access files in their own folder.

  • Finally, enable Realtime on the inspections table so the app can detect server-side status changes. Run this SQL:
-- Enable Realtime for the inspections table
alter publication supabase_realtime add table public.inspections;

What does this publication do?

Supabase Realtime uses a Postgres publication called supabase_realtime to track which tables broadcast change events. Adding inspections to this publication means your Flutter app can subscribe to real-time UPDATE events on that table (for example, when a supervisor changes an inspection's status remotely).

  • Verify Realtime is enabled by clicking Database in the left sidebar, then Publications.
  • Under supabase_realtime, you should see the inspections table listed.

Publication not showing the table?

If you see an error about the publication not existing, your project may not have the default supabase_realtime publication yet. Create it first with create publication supabase_realtime; and then re-run the alter publication command.

help me enable Realtime on my Supabase table

Your Supabase backend is fully configured. Next up, you will scaffold the Flutter project with the feature-first architecture and wire up all the dependencies.

Scaffold the Project and Architecture

Your Supabase backend is live with tables, security policies, and storage buckets ready to receive data. Now you need a Flutter project structured to actually talk to that backend.

Enterprise apps fall apart when code is dumped into a flat folder structure. In this step, you will create the Flutter project, install every dependency, and scaffold a feature-first architecture that keeps each concern isolated.

In this step, get ready to:
  • Create the Flutter project and install all dependencies.
  • Build the feature-first folder structure for Clean Architecture.
  • Configure dependency injection, Supabase, and Sentry in the app entry point.
Create the Flutter project and add dependencies

Every Flutter project starts with a scaffolded template. The --empty flag gives you a minimal main.dart without the default counter app boilerplate.

  • Open your terminal in VS Code.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Flutter project by running:
flutter create --empty field_inspector
  • Move into the new project folder by running:
cd field_inspector

What does --empty do?

The --empty flag creates a Flutter app with a minimal main.dart file instead of the default counter app template. This gives you a clean slate to build your own architecture from scratch.

This project uses over 20 packages for state management, database, code generation, hardware access, and more. You will add them all at once using flutter pub add.

  • Add all production dependencies by running:
flutter pub add supabase_flutter drift drift_flutter path_provider flutter_bloc bloc freezed_annotation json_annotation get_it injectable connectivity_plus geolocator image_picker mobile_scanner hand_signature pdf printing intl fpdart sentry_flutter fl_chart shimmer uuid
  • Add all dev dependencies by running:
flutter pub add --dev drift_dev build_runner freezed json_serializable injectable_generator bloc_test mocktail flutter_lints

Why so many packages?

Each package handles one responsibility. drift manages the local SQLite database. flutter_bloc handles state management. freezed generates immutable models. get_it and injectable wire up dependency injection.

This separation keeps your codebase modular and testable. Each layer can be swapped or mocked independently during testing.

  • Confirm the dependencies installed successfully by running:
flutter pub get

You should see a message indicating all packages resolved successfully with no errors.

Seeing dependency version conflicts?

If you see version solving errors, try running flutter pub upgrade to let the resolver pick compatible versions. Make sure your Flutter SDK is up to date by running flutter upgrade.

help me fix dependency version conflicts in my Flutter pubspec.yaml

Create the feature-first folder structure

Feature-first architecture groups code by what it does, not by what layer it belongs to. Each feature (auth, inspection, sync) gets its own data/domain/presentation folders.

What is feature-first Clean Architecture?

Traditional layer-first architecture puts all repositories in one folder, all blocs in another. Feature-first flips this: each feature is self-contained with its own data, domain, and presentation layers.

This means you can delete or refactor an entire feature without touching unrelated code. It also makes code reviews faster because related files live together.

  • Create the core module folders by running:
mkdir -p lib/core/database lib/core/di lib/core/error lib/core/logging lib/core/network lib/core/theme lib/core/usecases
  • Create the feature folders with their Clean Architecture layers by running:
mkdir -p lib/features/auth/data lib/features/auth/domain lib/features/auth/presentation
mkdir -p lib/features/inspection/data lib/features/inspection/domain lib/features/inspection/presentation
mkdir -p lib/features/scanner/data lib/features/scanner/domain lib/features/scanner/presentation
mkdir -p lib/features/signature/data lib/features/signature/domain lib/features/signature/presentation
mkdir -p lib/features/sync/data lib/features/sync/domain lib/features/sync/presentation
mkdir -p lib/features/report/data lib/features/report/domain lib/features/report/presentation
mkdir -p lib/features/dashboard/data lib/features/dashboard/domain lib/features/dashboard/presentation
  • Create the localization folder by running:
mkdir -p lib/l10n
  • Confirm the folder structure exists by running:
ls lib/core lib/features

You should see database, di, error, logging, network, theme, usecases under lib/core and all seven feature folders under lib/features.

Now add strict lint rules to catch problems early.

  • Open analysis_options.yaml in your project root and replace its contents with the following:
include: package:flutter_lints/flutter.yaml

linter:
  rules:
    # Error prevention
    avoid_print: true
    prefer_const_constructors: true
    prefer_const_declarations: true
    avoid_unnecessary_containers: true
    # Code quality
    prefer_final_locals: true
    sort_constructors_first: true
    unawaited_futures: true
    cancel_subscriptions: true

What do these lint rules do?

Lint rules are like a code reviewer that catches common mistakes automatically. avoid_print forces you to use proper logging instead of print() statements. unawaited_futures flags async calls you forgot to await.

  • Save analysis_options.yaml.
Set up dependency injection and initialize services

The app needs three services running before anything else: Sentry for crash reporting, Supabase for the backend, and GetIt for dependency injection. You will wire all three in main() and create the DI configuration file.

  • Create the DI configuration file at lib/core/di/injection.dart and paste the following code:
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'injection.config.dart';

// Global GetIt instance for dependency resolution
final getIt = GetIt.instance;

@InjectableInit(
  initializerName: 'init',
  preferRelativeImports: true,
  asExtension: true,
)
void configureDependencies() => getIt.init();

// Register third-party dependencies that injectable can't auto-detect
@module
abstract class RegisterModule {
  @singleton
  SupabaseClient get supabaseClient => Supabase.instance.client;
}

What does this code do?

The @InjectableInit annotation tells the code generator to scan your project and auto-register all annotated classes into GetIt. The configureDependencies() function calls the generated init() extension method.

The RegisterModule class uses the @module annotation to register third-party classes that injectable cannot annotate directly. Here it exposes the SupabaseClient as a singleton so any class can request it via DI.

  • Now replace the contents of lib/main.dart with the following code:
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'core/di/injection.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Sentry for crash reporting
  await SentryFlutter.init(
    (options) {
      options.dsn = 'your-sentry-dsn-here';
      options.tracesSampleRate = 1.0;
    },
    appRunner: () async {
      // Initialize Supabase backend connection
      await Supabase.initialize(
        url: 'your-supabase-project-url',
        anonKey: 'your-supabase-anon-key',
      );

      // Wire up dependency injection
      configureDependencies();

      runApp(const MyApp());
    },
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Field Inspector',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const Scaffold(
        body: Center(
          child: Text('Field Inspector'),
        ),
      ),
    );
  }
}

What does this code do?

The SentryFlutter.init method wraps your entire app in an error handler that automatically catches unhandled exceptions and sends them to your Sentry dashboard. The appRunner callback ensures Supabase and DI are ready before the app renders.

The Supabase.initialize call connects your app to the Supabase project you created in Step 2. After both services are live, configureDependencies() registers all injectable classes into GetIt.

Replace the placeholder values

Replace your-sentry-dsn-here with the Sentry DSN you copied in Step 1. Replace your-supabase-project-url and your-supabase-anon-key with the Project URL and publishable API key from your Supabase dashboard.

  • Save both lib/core/di/injection.dart and lib/main.dart.
  • Run the code generator to produce the DI config file:
flutter pub run build_runner build --delete-conflicting-outputs

You should see output ending with a success message indicating the build completed without errors. A new file lib/core/di/injection.config.dart is generated automatically.

What is build_runner?

The build_runner tool reads annotations like @InjectableInit and @module in your code, then generates implementation files. You will run it frequently as you add new injectable classes throughout the project.

Build runner failing?

If you see import errors in the generated file, make sure you saved injection.dart before running build_runner. The --delete-conflicting-outputs flag clears stale generated files that may conflict.

help me fix build_runner errors in my Flutter project with injectable and get_it

  • Verify everything compiles by running:
flutter analyze

You should see "No issues found" or only minor info-level messages. Any errors at this point mean a typo or missing import.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files before moving on.

ⓧ I'd like to double check the full code

Here is the complete lib/core/di/injection.dart:

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'injection.config.dart';

// Global GetIt instance for dependency resolution
final getIt = GetIt.instance;

@InjectableInit(
  initializerName: 'init',
  preferRelativeImports: true,
  asExtension: true,
)
void configureDependencies() => getIt.init();

// Register third-party dependencies that injectable can't auto-detect
@module
abstract class RegisterModule {
  @singleton
  SupabaseClient get supabaseClient => Supabase.instance.client;
}

Here is the complete lib/main.dart:

import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'core/di/injection.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Sentry for crash reporting
  await SentryFlutter.init(
    (options) {
      options.dsn = 'your-sentry-dsn-here';
      options.tracesSampleRate = 1.0;
    },
    appRunner: () async {
      // Initialize Supabase backend connection
      await Supabase.initialize(
        url: 'your-supabase-project-url',
        anonKey: 'your-supabase-anon-key',
      );

      // Wire up dependency injection
      configureDependencies();

      runApp(const MyApp());
    },
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Field Inspector',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const Scaffold(
        body: Center(
          child: Text('Field Inspector'),
        ),
      ),
    );
  }
}

Your project skeleton is locked in. You have a feature-first architecture, dependency injection wired up, and Supabase plus Sentry initializing before the app renders. Next up, you will build the authentication flow with Supabase Auth and BLoC.

Build Authentication with Supabase Auth

Your project skeleton is ready with dependency injection, Supabase initialization, and a feature-first folder structure. Now you need to protect the app so only authenticated users can access inspection data.

In this step, you will build the authentication layer using Supabase Auth and the BLoC pattern. The BLoC manages auth state changes reactively, and Supabase handles the actual sign-in and sign-up logic on the server.

In this step, get ready to:
  • Create an AuthBloc with Freezed events and states that wraps Supabase Auth.
  • Build a login/signup screen with form validation and error display.
  • Wire an AuthGate widget into MaterialApp to route users based on auth state.
Create the AuthBloc with Freezed events and states

The BLoC pattern separates business logic from the UI by processing events and emitting states. Your AuthBloc will react to login requests, signup requests, and auth state changes from Supabase.

  • Create a new file at lib/features/auth/presentation/bloc/auth_event.dart and add the following Freezed event definitions:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_event.freezed.dart';

// Defines all possible authentication events the UI can trigger
@freezed
sealed class AuthEvent with _$AuthEvent {
  // User taps login with email and password
  const factory AuthEvent.loginRequested({
    required String email,
    required String password,
  }) = LoginRequested;

  // User taps sign up with email and password
  const factory AuthEvent.signUpRequested({
    required String email,
    required String password,
  }) = SignUpRequested;

  // User taps logout
  const factory AuthEvent.logoutRequested() = LogoutRequested;

  // App checks for an existing session on startup
  const factory AuthEvent.authCheckRequested() = AuthCheckRequested;
}

What does this code do?

Each factory constructor defines a distinct event the AuthBloc can process. LoginRequested and SignUpRequested carry email and password payloads. LogoutRequested has no payload. AuthCheckRequested fires on app startup to check for an existing session.

Freezed generates the boilerplate (equality, copyWith, toString) from these definitions when you run build_runner.

  • Create lib/features/auth/presentation/bloc/auth_state.dart with the Freezed state definitions:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_state.freezed.dart';

// Defines all possible auth states the UI reacts to
@freezed
sealed class AuthState with _$AuthState {
  // Initial state before any check has been performed
  const factory AuthState.initial() = AuthInitial;

  // Auth operation in progress (login, signup, or session check)
  const factory AuthState.loading() = AuthLoading;

  // User is authenticated, carries the user ID for RLS
  const factory AuthState.authenticated({required String userId}) = Authenticated;

  // No valid session exists
  const factory AuthState.unauthenticated() = Unauthenticated;

  // Auth operation failed with a message
  const factory AuthState.error({required String message}) = AuthError;
}

Why store the userId in Authenticated state?

Row Level Security policies on your Supabase tables filter data by auth.uid(). Storing the user ID in the auth state makes it accessible throughout the app for sync operations and data queries without needing to call Supabase again.

  • Create lib/features/auth/presentation/bloc/auth_bloc.dart with the BLoC that connects events to Supabase Auth:
import 'dart:async';

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

import 'auth_event.dart';
import 'auth_state.dart';

// Manages authentication state by wrapping Supabase Auth
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final SupabaseClient _supabase;
  StreamSubscription<AuthState>? _authSubscription;

  AuthBloc({required SupabaseClient supabaseClient})
      : _supabase = supabaseClient,
        super(const AuthState.initial()) {
    // Register event handlers
    on<AuthCheckRequested>(_onAuthCheckRequested);
    on<LoginRequested>(_onLoginRequested);
    on<SignUpRequested>(_onSignUpRequested);
    on<LogoutRequested>(_onLogoutRequested);

    // Listen to Supabase auth state changes
    _supabase.auth.onAuthStateChange.listen(
      (data) {
        final session = data.session;
        if (session != null) {
          add(const AuthEvent.authCheckRequested());
        } else {
          emit(const AuthState.unauthenticated());
        }
      },
      onError: (error) {
        emit(AuthState.error(message: error.toString()));
      },
    );
  }

  Future<void> _onAuthCheckRequested(
    AuthCheckRequested event,
    Emitter<AuthState> emit,
  ) async {
    // Check if a valid session already exists
    final session = _supabase.auth.currentSession;
    if (session != null) {
      emit(AuthState.authenticated(userId: session.user.id));
    } else {
      emit(const AuthState.unauthenticated());
    }
  }

  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthState.loading());
    try {
      final response = await _supabase.auth.signInWithPassword(
        email: event.email,
        password: event.password,
      );
      final user = response.user;
      if (user != null) {
        emit(AuthState.authenticated(userId: user.id));
      } else {
        emit(const AuthState.error(message: 'Login failed. Please try again.'));
      }
    } on AuthException catch (e) {
      emit(AuthState.error(message: e.message));
    } catch (e) {
      emit(AuthState.error(message: 'An unexpected error occurred.'));
    }
  }

  Future<void> _onSignUpRequested(
    SignUpRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthState.loading());
    try {
      final response = await _supabase.auth.signUp(
        email: event.email,
        password: event.password,
      );
      final user = response.user;
      if (user != null) {
        emit(AuthState.authenticated(userId: user.id));
      } else {
        emit(const AuthState.error(message: 'Sign up failed. Please try again.'));
      }
    } on AuthException catch (e) {
      emit(AuthState.error(message: e.message));
    } catch (e) {
      emit(AuthState.error(message: 'An unexpected error occurred.'));
    }
  }

  Future<void> _onLogoutRequested(
    LogoutRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthState.loading());
    await _supabase.auth.signOut();
    emit(const AuthState.unauthenticated());
  }

  @override
  Future<void> close() {
    _authSubscription?.cancel();
    return super.close();
  }
}

What does this code do?

  • The constructor registers handlers for each event type using on<EventType>.
  • It subscribes to onAuthStateChange so the BLoC reacts to external auth changes (token refresh, session expiry).
  • Login calls signInWithPassword and emits Authenticated with the user ID on success.
  • Sign up calls signUp and follows the same success/error pattern.
  • Errors are caught as AuthException (from Supabase) for user-friendly messages.
  • Run code generation to produce the Freezed files:
flutter pub run build_runner build --delete-conflicting-outputs
  • Confirm that auth_event.freezed.dart and auth_state.freezed.dart were generated inside the bloc/ folder.

Build runner failed?

  • Make sure every part directive matches the filename exactly. auth_event.dart must declare part 'auth_event.freezed.dart';.
  • Check that freezed and build_runner are in your dev_dependencies. Run flutter pub get first if packages are missing.
  • If you see a conflict error, the --delete-conflicting-outputs flag resolves it by removing stale generated files.

Why is build_runner failing for my Freezed files?

Build the login and signup screen

The login page gives users a way to sign in or create an account. It uses BlocListener to react to state changes for navigation and error display, keeping the UI free of business logic.

  • Create lib/features/auth/presentation/pages/login_page.dart with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/auth_bloc.dart';
import '../bloc/auth_event.dart';
import '../bloc/auth_state.dart';

// Login and signup screen with toggle between modes
class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isSignUp = false; // Toggle between login and sign up mode

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submit() {
    if (!_formKey.currentState!.validate()) return;

    final email = _emailController.text.trim();
    final password = _passwordController.text.trim();

    if (_isSignUp) {
      context.read<AuthBloc>().add(
            AuthEvent.signUpRequested(email: email, password: password),
          );
    } else {
      context.read<AuthBloc>().add(
            AuthEvent.loginRequested(email: email, password: password),
          );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_isSignUp ? 'Create Account' : 'Sign In'),
      ),
      body: BlocListener<AuthBloc, AuthState>(
        listener: (context, state) {
          // Show error snackbar when auth fails
          if (state is AuthError) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(state.message),
                backgroundColor: Colors.red,
              ),
            );
          }
        },
        child: Padding(
          padding: const EdgeInsets.all(24.0),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // Email input field with validation
                TextFormField(
                  controller: _emailController,
                  decoration: const InputDecoration(
                    labelText: 'Email',
                    border: OutlineInputBorder(),
                  ),
                  keyboardType: TextInputType.emailAddress,
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Please enter your email';
                    }
                    if (!value.contains('@')) {
                      return 'Please enter a valid email';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 16),

                // Password input field with validation
                TextFormField(
                  controller: _passwordController,
                  decoration: const InputDecoration(
                    labelText: 'Password',
                    border: OutlineInputBorder(),
                  ),
                  obscureText: true,
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Please enter your password';
                    }
                    if (value.length < 6) {
                      return 'Password must be at least 6 characters';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 24),

                // Submit button shows loading state
                BlocBuilder<AuthBloc, AuthState>(
                  builder: (context, state) {
                    final isLoading = state is AuthLoading;
                    return ElevatedButton(
                      onPressed: isLoading ? null : _submit,
                      child: isLoading
                          ? const CircularProgressIndicator()
                          : Text(_isSignUp ? 'Sign Up' : 'Login'),
                    );
                  },
                ),
                const SizedBox(height: 16),

                // Toggle between login and sign up modes
                TextButton(
                  onPressed: () {
                    setState(() {
                      _isSignUp = !_isSignUp;
                    });
                  },
                  child: Text(
                    _isSignUp
                        ? 'Already have an account? Login'
                        : 'Don\'t have an account? Sign Up',
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

What does this code do?

  • A Form widget with a GlobalKey handles validation before submitting.
  • BlocListener catches AuthError states and shows a SnackBar. This is a one-time side effect, so it uses listener (not builder).
  • BlocBuilder around the submit button disables it during loading and shows a spinner.
  • The _isSignUp boolean toggles between login and sign-up modes, changing the button text and which event is dispatched.
  • Save login_page.dart.

Seeing import errors?

  • If your IDE shows red underlines on the AuthState or AuthEvent imports, make sure build_runner completed successfully. The generated .freezed.dart files must exist for the code to compile.
  • Check that your import paths match the folder structure exactly. The BLoC files live at lib/features/auth/presentation/bloc/.

Why am I seeing unresolved import errors in my auth files?

Create the AuthGate and wire it into MaterialApp

The AuthGate widget acts as a router. It watches the auth state and shows either the login screen or the home screen. This pattern keeps routing logic in one place rather than scattering it across the app.

  • Create lib/features/auth/presentation/widgets/auth_gate.dart with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/auth_bloc.dart';
import '../bloc/auth_event.dart';
import '../bloc/auth_state.dart';
import '../pages/login_page.dart';

// Routes to LoginPage or HomePage based on current auth state
class AuthGate extends StatelessWidget {
  const AuthGate({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<AuthBloc, AuthState>(
      builder: (context, state) {
        // Show loading indicator during auth check
        if (state is AuthLoading || state is AuthInitial) {
          return const Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }

        // User is authenticated, show home screen
        if (state is Authenticated) {
          return const Scaffold(
            body: Center(
              child: Text('Welcome! Inspection list coming soon.'),
            ),
          );
        }

        // User is not authenticated, show login
        return const LoginPage();
      },
    );
  }
}

What does this code do?

BlocBuilder rebuilds whenever the AuthBloc emits a new state. During AuthInitial or AuthLoading, it shows a spinner. When Authenticated, it shows a placeholder home screen (you will replace this with the inspection list in a later step). Otherwise it falls through to LoginPage.

  • Open lib/main.dart and update it to provide the AuthBloc and use AuthGate as the home widget. Find the runApp call and replace the app widget with the following:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

import 'core/di/injection.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart';
import 'features/auth/presentation/widgets/auth_gate.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Sentry for crash reporting
  await SentryFlutter.init(
    (options) {
      options.dsn = 'your-sentry-dsn-here';
    },
  );

  // Initialize Supabase client
  await Supabase.initialize(
    url: 'your-supabase-url-here',
    anonKey: 'your-supabase-anon-key-here',
  );

  // Configure dependency injection
  configureDependencies();

  runApp(const FieldInspectorApp());
}

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // Create AuthBloc with the Supabase client from DI
      create: (context) => AuthBloc(
        supabaseClient: GetIt.instance<SupabaseClient>(),
      )..add(const AuthEvent.authCheckRequested()),
      child: MaterialApp(
        title: 'Field Inspector',
        theme: ThemeData(
          colorSchemeSeed: Colors.indigo,
          useMaterial3: true,
        ),
        home: const AuthGate(),
      ),
    );
  }
}

What does this code do?

  • BlocProvider creates the AuthBloc and makes it available to all descendant widgets.
  • The cascade operator ..add(const AuthEvent.authCheckRequested()) fires immediately after creation. This triggers auto-login by checking if a valid session exists.
  • GetIt.instance<SupabaseClient>() retrieves the registered Supabase client from your DI container.
  • AuthGate is set as the home widget, so auth routing is the first thing users encounter.
  • Save main.dart.
  • Launch your emulator and run the app:
flutter run
  • You should see the login screen with email and password fields, a Login button, and a toggle link to switch to sign-up mode.

App crashes on launch?

  • Verify your Supabase URL and anon key are correct in main.dart. An invalid URL causes a connection failure at startup.
  • Make sure your Android emulator is running. Run flutter devices to confirm a device is connected.
  • If you see a GetIt error about SupabaseClient not being registered, confirm your injection.dart registers it correctly and that configureDependencies() is called before runApp.

Why is my app crashing on launch with a DI or Supabase error?

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the full code for each file at this point:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_event.freezed.dart';

// Defines all possible authentication events the UI can trigger
@freezed
sealed class AuthEvent with _$AuthEvent {
  // User taps login with email and password
  const factory AuthEvent.loginRequested({
    required String email,
    required String password,
  }) = LoginRequested;

  // User taps sign up with email and password
  const factory AuthEvent.signUpRequested({
    required String email,
    required String password,
  }) = SignUpRequested;

  // User taps logout
  const factory AuthEvent.logoutRequested() = LogoutRequested;

  // App checks for an existing session on startup
  const factory AuthEvent.authCheckRequested() = AuthCheckRequested;
}
import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_state.freezed.dart';

// Defines all possible auth states the UI reacts to
@freezed
sealed class AuthState with _$AuthState {
  // Initial state before any check has been performed
  const factory AuthState.initial() = AuthInitial;

  // Auth operation in progress (login, signup, or session check)
  const factory AuthState.loading() = AuthLoading;

  // User is authenticated, carries the user ID for RLS
  const factory AuthState.authenticated({required String userId}) = Authenticated;

  // No valid session exists
  const factory AuthState.unauthenticated() = Unauthenticated;

  // Auth operation failed with a message
  const factory AuthState.error({required String message}) = AuthError;
}
import 'dart:async';

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

import 'auth_event.dart';
import 'auth_state.dart';

// Manages authentication state by wrapping Supabase Auth
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final SupabaseClient _supabase;
  StreamSubscription<AuthState>? _authSubscription;

  AuthBloc({required SupabaseClient supabaseClient})
      : _supabase = supabaseClient,
        super(const AuthState.initial()) {
    // Register event handlers
    on<AuthCheckRequested>(_onAuthCheckRequested);
    on<LoginRequested>(_onLoginRequested);
    on<SignUpRequested>(_onSignUpRequested);
    on<LogoutRequested>(_onLogoutRequested);

    // Listen to Supabase auth state changes
    _supabase.auth.onAuthStateChange.listen(
      (data) {
        final session = data.session;
        if (session != null) {
          add(const AuthEvent.authCheckRequested());
        } else {
          emit(const AuthState.unauthenticated());
        }
      },
      onError: (error) {
        emit(AuthState.error(message: error.toString()));
      },
    );
  }

  Future<void> _onAuthCheckRequested(
    AuthCheckRequested event,
    Emitter<AuthState> emit,
  ) async {
    // Check if a valid session already exists
    final session = _supabase.auth.currentSession;
    if (session != null) {
      emit(AuthState.authenticated(userId: session.user.id));
    } else {
      emit(const AuthState.unauthenticated());
    }
  }

  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthState.loading());
    try {
      final response = await _supabase.auth.signInWithPassword(
        email: event.email,
        password: event.password,
      );
      final user = response.user;
      if (user != null) {
        emit(AuthState.authenticated(userId: user.id));
      } else {
        emit(const AuthState.error(message: 'Login failed. Please try again.'));
      }
    } on AuthException catch (e) {
      emit(AuthState.error(message: e.message));
    } catch (e) {
      emit(AuthState.error(message: 'An unexpected error occurred.'));
    }
  }

  Future<void> _onSignUpRequested(
    SignUpRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthState.loading());
    try {
      final response = await _supabase.auth.signUp(
        email: event.email,
        password: event.password,
      );
      final user = response.user;
      if (user != null) {
        emit(AuthState.authenticated(userId: user.id));
      } else {
        emit(const AuthState.error(message: 'Sign up failed. Please try again.'));
      }
    } on AuthException catch (e) {
      emit(AuthState.error(message: e.message));
    } catch (e) {
      emit(AuthState.error(message: 'An unexpected error occurred.'));
    }
  }

  Future<void> _onLogoutRequested(
    LogoutRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthState.loading());
    await _supabase.auth.signOut();
    emit(const AuthState.unauthenticated());
  }

  @override
  Future<void> close() {
    _authSubscription?.cancel();
    return super.close();
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/auth_bloc.dart';
import '../bloc/auth_event.dart';
import '../bloc/auth_state.dart';

// Login and signup screen with toggle between modes
class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isSignUp = false; // Toggle between login and sign up mode

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submit() {
    if (!_formKey.currentState!.validate()) return;

    final email = _emailController.text.trim();
    final password = _passwordController.text.trim();

    if (_isSignUp) {
      context.read<AuthBloc>().add(
            AuthEvent.signUpRequested(email: email, password: password),
          );
    } else {
      context.read<AuthBloc>().add(
            AuthEvent.loginRequested(email: email, password: password),
          );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_isSignUp ? 'Create Account' : 'Sign In'),
      ),
      body: BlocListener<AuthBloc, AuthState>(
        listener: (context, state) {
          // Show error snackbar when auth fails
          if (state is AuthError) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(state.message),
                backgroundColor: Colors.red,
              ),
            );
          }
        },
        child: Padding(
          padding: const EdgeInsets.all(24.0),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // Email input field with validation
                TextFormField(
                  controller: _emailController,
                  decoration: const InputDecoration(
                    labelText: 'Email',
                    border: OutlineInputBorder(),
                  ),
                  keyboardType: TextInputType.emailAddress,
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Please enter your email';
                    }
                    if (!value.contains('@')) {
                      return 'Please enter a valid email';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 16),

                // Password input field with validation
                TextFormField(
                  controller: _passwordController,
                  decoration: const InputDecoration(
                    labelText: 'Password',
                    border: OutlineInputBorder(),
                  ),
                  obscureText: true,
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Please enter your password';
                    }
                    if (value.length < 6) {
                      return 'Password must be at least 6 characters';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 24),

                // Submit button shows loading state
                BlocBuilder<AuthBloc, AuthState>(
                  builder: (context, state) {
                    final isLoading = state is AuthLoading;
                    return ElevatedButton(
                      onPressed: isLoading ? null : _submit,
                      child: isLoading
                          ? const CircularProgressIndicator()
                          : Text(_isSignUp ? 'Sign Up' : 'Login'),
                    );
                  },
                ),
                const SizedBox(height: 16),

                // Toggle between login and sign up modes
                TextButton(
                  onPressed: () {
                    setState(() {
                      _isSignUp = !_isSignUp;
                    });
                  },
                  child: Text(
                    _isSignUp
                        ? 'Already have an account? Login'
                        : 'Don\'t have an account? Sign Up',
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/auth_bloc.dart';
import '../bloc/auth_event.dart';
import '../bloc/auth_state.dart';
import '../pages/login_page.dart';

// Routes to LoginPage or HomePage based on current auth state
class AuthGate extends StatelessWidget {
  const AuthGate({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<AuthBloc, AuthState>(
      builder: (context, state) {
        // Show loading indicator during auth check
        if (state is AuthLoading || state is AuthInitial) {
          return const Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }

        // User is authenticated, show home screen
        if (state is Authenticated) {
          return const Scaffold(
            body: Center(
              child: Text('Welcome! Inspection list coming soon.'),
            ),
          );
        }

        // User is not authenticated, show login
        return const LoginPage();
      },
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

import 'core/di/injection.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart';
import 'features/auth/presentation/widgets/auth_gate.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Sentry for crash reporting
  await SentryFlutter.init(
    (options) {
      options.dsn = 'your-sentry-dsn-here';
    },
  );

  // Initialize Supabase client
  await Supabase.initialize(
    url: 'your-supabase-url-here',
    anonKey: 'your-supabase-anon-key-here',
  );

  // Configure dependency injection
  configureDependencies();

  runApp(const FieldInspectorApp());
}

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // Create AuthBloc with the Supabase client from DI
      create: (context) => AuthBloc(
        supabaseClient: GetIt.instance<SupabaseClient>(),
      )..add(const AuthEvent.authCheckRequested()),
      child: MaterialApp(
        title: 'Field Inspector',
        theme: ThemeData(
          colorSchemeSeed: Colors.indigo,
          useMaterial3: true,
        ),
        home: const AuthGate(),
      ),
    );
  }
}

Your authentication layer is locked in. Users can sign up, log in, and the app automatically checks for existing sessions on launch. Next up, you will define the Drift database tables to store inspection data locally.

Define the Drift Database and Tables

Your app needs to store inspection data locally so it works without connectivity. The local database is the foundation of your offline-first architecture.

In this step, you will define type-safe database tables using Drift, create DAOs (Data Access Objects) for clean database queries, and register the database in your DI container.

In this step, get ready to:
  • Define five Drift table classes with type-safe columns and primary keys.
  • Create five DAOs with watch, insert, update, and delete methods.
  • Write a database integration test that inserts and queries an inspection.
Create the Drift table definitions

Drift tables are Dart classes that extend Table. Each column is a late final field that declares the column type and ends with an extra pair of parentheses.

  • Create the folder lib/core/database/tables/ inside your field_inspector project.
  • Create the file inspections_table.dart inside lib/core/database/tables/ and add the following code:
import 'package:drift/drift.dart';

// Stores inspection records with location, category, and sync status
class Inspections extends Table {
  // UUID primary key generated client-side
  late final id = text()();
  // Links inspection to authenticated user for RLS
  late final userId = text()();
  late final title = text()();
  late final locationName = text()();
  // GPS coordinates captured on device
  late final latitude = real().nullable()();
  late final longitude = real().nullable()();
  // Category: Structural, Electrical, Plumbing, Fire Safety, General
  late final category = text()();
  // Status tracks inspection lifecycle: draft, in_progress, completed
  late final status = text().withDefault(const Constant('draft'))();
  // QR/barcode scanned asset identifier
  late final assetId = text().nullable()();
  late final createdAt = dateTime()();
  late final updatedAt = dateTime()();

  @override
  Set<Column<Object>> get primaryKey => {id};
}

What does this code do?

This defines the Inspections table with a UUID text primary key instead of an auto-incrementing integer. UUID primary keys let you generate IDs client-side without needing a database round-trip. This is essential for offline-first apps where the device creates records without server contact.

The primaryKey getter override tells Drift to use id as the primary key instead of looking for an auto-increment column.

  • Save inspections_table.dart.
  • Create inspection_items_table.dart in the same tables/ folder with this code:
import 'package:drift/drift.dart';

// Individual checklist items within an inspection
class InspectionItems extends Table {
  late final id = text()();
  // Foreign key linking to parent inspection
  late final inspectionId = text()();
  late final description = text()();
  // Severity level: low, medium, high, critical
  late final severity = text().withDefault(const Constant('low'))();
  // Whether this checklist item has been completed
  late final isCompleted = boolean()();
  // Optional notes for additional context
  late final notes = text().nullable()();
  late final createdAt = dateTime()();

  @override
  Set<Column<Object>> get primaryKey => {id};
}

What does this code do?

Each inspection contains multiple checklist items. The inspectionId field links each item to its parent inspection. The severity field defaults to 'low' so new items start at the lowest severity level.

  • Save inspection_items_table.dart.
  • Create inspection_photos_table.dart in the same folder:
import 'package:drift/drift.dart';

// Photos attached to individual inspection items
class InspectionPhotos extends Table {
  late final id = text()();
  // Links photo to a specific checklist item
  late final inspectionItemId = text()();
  // Local file path to the photo on device storage
  late final storagePath = text()();
  // Optional description of what the photo shows
  late final caption = text().nullable()();
  late final createdAt = dateTime()();

  @override
  Set<Column<Object>> get primaryKey => {id};
}

What does this code do?

Photos are linked to individual checklist items via inspectionItemId. The storagePath stores the local file path on the device. Photos live on disk, not inside SQLite, to avoid bloating the database.

  • Save inspection_photos_table.dart.
  • Create inspection_signatures_table.dart in the same folder:
import 'package:drift/drift.dart';

// Digital signatures for inspection sign-off
class InspectionSignatures extends Table {
  late final id = text()();
  // Links signature to the parent inspection
  late final inspectionId = text()();
  // Local file path to the signature PNG
  late final storagePath = text()();
  // Name of the person who signed
  late final signerName = text()();
  late final createdAt = dateTime()();

  @override
  Set<Column<Object>> get primaryKey => {id};
}
  • Save inspection_signatures_table.dart.
  • Create sync_queue_table.dart in the same folder:
import 'package:drift/drift.dart';

// Queue of pending sync operations for offline-first architecture
class SyncQueue extends Table {
  // Auto-incrementing ID ensures FIFO processing order
  late final id = integer().autoIncrement()();
  // Which table the change belongs to (e.g. 'inspections', 'inspection_items')
  late final tableName = text()();
  // UUID of the record that was changed
  late final recordId = text()();
  // Type of change: 'insert', 'update', or 'delete'
  late final action = text()();
  // JSON payload of the full record for sync
  late final payload = text()();
  late final createdAt = dateTime()();
  // Null until successfully synced to server
  late final syncedAt = dateTime().nullable()();
  // Tracks retry attempts for exponential backoff
  late final retryCount = integer().withDefault(const Constant(0))();
}

Why does SyncQueue use autoIncrement instead of a UUID?

The sync queue uses an auto-incrementing integer ID because processing order matters. When connectivity returns, you need to replay changes in the exact order they happened. An auto-incrementing integer guarantees FIFO ordering without needing timestamps.

  • Save sync_queue_table.dart.
  • Create the main database file app_database.dart in lib/core/database/ with the following code:
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';

import 'tables/inspections_table.dart';
import 'tables/inspection_items_table.dart';
import 'tables/inspection_photos_table.dart';
import 'tables/inspection_signatures_table.dart';
import 'tables/sync_queue_table.dart';
import 'daos/inspection_dao.dart';
import 'daos/inspection_item_dao.dart';
import 'daos/inspection_photo_dao.dart';
import 'daos/signature_dao.dart';
import 'daos/sync_queue_dao.dart';

part 'app_database.g.dart';

// Central database class referencing all tables and DAOs
@DriftDatabase(
  tables: [
    Inspections,
    InspectionItems,
    InspectionPhotos,
    InspectionSignatures,
    SyncQueue,
  ],
  daos: [
    InspectionDao,
    InspectionItemDao,
    InspectionPhotoDao,
    SignatureDao,
    SyncQueueDao,
  ],
)
class AppDatabase extends _$AppDatabase {
  AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());

  @override
  int get schemaVersion => 1;

  static QueryExecutor _openConnection() {
    return driftDatabase(
      name: 'field_inspector_db',
      native: const DriftNativeOptions(
        databaseDirectory: getApplicationSupportDirectory,
      ),
    );
  }
}

What does this code do?

The @DriftDatabase annotation tells the code generator which tables and DAOs to include. The generated _$AppDatabase superclass contains all the wiring between tables, DAOs, and queries.

The driftDatabase() function from drift_flutter handles opening the SQLite database file. The DriftNativeOptions configures the storage location using path_provider to point at the application support directory.

  • Save app_database.dart.

Seeing red squiggles in VS Code?

The part 'app_database.g.dart' line and _$AppDatabase reference will show errors until you run the code generator. The DAO imports will also show errors because those files do not exist yet. This is expected and resolves after the next substep.

help me understand why my Drift database file has errors before running build_runner

Create the DAOs

DAOs group related database queries into separate classes, keeping your code modular. Each DAO handles one table and exposes stream-based watches, inserts, updates, and deletes.

  • Create the folder lib/core/database/daos/ inside your project.
  • Create inspection_dao.dart in the daos/ folder with this code:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/inspections_table.dart';

part 'inspection_dao.g.dart';

// Handles all database operations for inspections
@DriftAccessor(tables: [Inspections])
class InspectionDao extends DatabaseAccessor<AppDatabase>
    with _$InspectionDaoMixin {
  InspectionDao(super.attachedDatabase);

  // Stream of all inspections, auto-updates when data changes
  Stream<List<Inspection>> watchAll() => select(inspections).watch();

  // Get a single inspection by its UUID
  Future<Inspection> getById(String id) =>
      (select(inspections)..where((t) => t.id.equals(id))).getSingle();

  // Insert a new inspection record
  Future<int> insertInspection(InspectionsCompanion entry) =>
      into(inspections).insert(entry);

  // Update an existing inspection
  Future<bool> updateInspection(InspectionsCompanion entry, String id) =>
      (update(inspections)..where((t) => t.id.equals(id))).write(entry);

  // Delete an inspection by its UUID
  Future<int> deleteInspection(String id) =>
      (delete(inspections)..where((t) => t.id.equals(id))).go();
}

What does this code do?

The @DriftAccessor annotation tells the code generator this class needs access to the Inspections table. The generated _$InspectionDaoMixin provides the inspections getter for building queries.

The watchAll() method returns a Stream that automatically emits new data whenever the table changes. This powers reactive UI updates without manual refresh logic.

  • Save inspection_dao.dart.
  • Create inspection_item_dao.dart in the daos/ folder:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/inspection_items_table.dart';

part 'inspection_item_dao.g.dart';

// Handles database operations for inspection checklist items
@DriftAccessor(tables: [InspectionItems])
class InspectionItemDao extends DatabaseAccessor<AppDatabase>
    with _$InspectionItemDaoMixin {
  InspectionItemDao(super.attachedDatabase);

  // Watch all items for a specific inspection
  Stream<List<InspectionItem>> watch(String inspectionId) =>
      (select(inspectionItems)
            ..where((t) => t.inspectionId.equals(inspectionId)))
          .watch();

  // Insert a new checklist item
  Future<int> insertItem(InspectionItemsCompanion entry) =>
      into(inspectionItems).insert(entry);

  // Update an existing checklist item
  Future<bool> updateItem(InspectionItemsCompanion entry, String id) =>
      (update(inspectionItems)..where((t) => t.id.equals(id))).write(entry);

  // Delete a checklist item by ID
  Future<int> deleteItem(String id) =>
      (delete(inspectionItems)..where((t) => t.id.equals(id))).go();
}
  • Save inspection_item_dao.dart.
  • Create inspection_photo_dao.dart in the daos/ folder:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/inspection_photos_table.dart';

part 'inspection_photo_dao.g.dart';

// Handles database operations for inspection photos
@DriftAccessor(tables: [InspectionPhotos])
class InspectionPhotoDao extends DatabaseAccessor<AppDatabase>
    with _$InspectionPhotoDaoMixin {
  InspectionPhotoDao(super.attachedDatabase);

  // Watch all photos for a specific inspection item
  Stream<List<InspectionPhoto>> watch(String inspectionItemId) =>
      (select(inspectionPhotos)
            ..where((t) => t.inspectionItemId.equals(inspectionItemId)))
          .watch();

  // Insert a new photo record
  Future<int> insertPhoto(InspectionPhotosCompanion entry) =>
      into(inspectionPhotos).insert(entry);

  // Delete a photo by ID
  Future<int> deletePhoto(String id) =>
      (delete(inspectionPhotos)..where((t) => t.id.equals(id))).go();
}
  • Save inspection_photo_dao.dart.
  • Create signature_dao.dart in the daos/ folder:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/inspection_signatures_table.dart';

part 'signature_dao.g.dart';

// Handles database operations for digital signatures
@DriftAccessor(tables: [InspectionSignatures])
class SignatureDao extends DatabaseAccessor<AppDatabase>
    with _$SignatureDaoMixin {
  SignatureDao(super.attachedDatabase);

  // Watch all signatures for a specific inspection
  Stream<List<InspectionSignature>> watch(String inspectionId) =>
      (select(inspectionSignatures)
            ..where((t) => t.inspectionId.equals(inspectionId)))
          .watch();

  // Insert a new signature record
  Future<int> insertSignature(InspectionSignaturesCompanion entry) =>
      into(inspectionSignatures).insert(entry);

  // Delete a signature by ID
  Future<int> deleteSignature(String id) =>
      (delete(inspectionSignatures)..where((t) => t.id.equals(id))).go();
}
  • Save signature_dao.dart.
  • Create sync_queue_dao.dart in the daos/ folder:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/sync_queue_table.dart';

part 'sync_queue_dao.g.dart';

// Handles database operations for the offline sync queue
@DriftAccessor(tables: [SyncQueue])
class SyncQueueDao extends DatabaseAccessor<AppDatabase>
    with _$SyncQueueDaoMixin {
  SyncQueueDao(super.attachedDatabase);

  // Watch all entries that have not been synced yet
  Stream<List<SyncQueueData>> watchPending() =>
      (select(syncQueue)..where((t) => t.syncedAt.isNull())).watch();

  // Insert a new sync queue entry
  Future<int> insertEntry(SyncQueueCompanion entry) =>
      into(syncQueue).insert(entry);

  // Mark an entry as successfully synced with current timestamp
  Future<bool> markSynced(int id) =>
      (update(syncQueue)..where((t) => t.id.equals(id)))
          .write(SyncQueueCompanion(syncedAt: Value(DateTime.now())));

  // Increment retry count for failed sync attempts
  Future<bool> incrementRetry(int id, int currentCount) =>
      (update(syncQueue)..where((t) => t.id.equals(id)))
          .write(SyncQueueCompanion(retryCount: Value(currentCount + 1)));

  // Delete a sync queue entry
  Future<int> deleteEntry(int id) =>
      (delete(syncQueue)..where((t) => t.id.equals(id))).go();
}

What does this code do?

The SyncQueueDao manages the offline sync queue. watchPending() streams all unsynced entries where syncedAt is null. markSynced() stamps entries with the current time. incrementRetry() tracks failed attempts for exponential backoff logic.

  • Save sync_queue_dao.dart.
  • Register the database as a singleton in your DI container. Open lib/core/di/injection.dart and replace its contents with this updated version that adds AppDatabase alongside the existing SupabaseClient registration:
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../database/app_database.dart';

import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() => getIt.init();

@module
abstract class RegisterModule {
  @singleton
  SupabaseClient get supabaseClient => Supabase.instance.client;

  // Register AppDatabase as a singleton so all DAOs share one instance
  @singleton
  AppDatabase get appDatabase => AppDatabase();
}

Why register the database as a singleton?

A Drift database holds an open connection to the SQLite file. Creating multiple instances causes lock contention and data corruption. A singleton ensures every DAO and repository shares the same database connection throughout the app lifecycle.

  • Run the code generator to produce the generated files by running this command in your terminal:
dart run build_runner build --delete-conflicting-outputs
  • You should see output indicating that multiple .g.dart files were generated, including app_database.g.dart and one for each DAO.

Build runner failing with errors?

Make sure all your table files import package:drift/drift.dart and that app_database.dart imports all table and DAO files. Check that every DAO file has the correct part statement matching its own filename (e.g. part 'inspection_dao.g.dart';).

help me debug build_runner errors in my Drift database setup

Write a database integration test

A quick integration test confirms your tables and DAOs work correctly before you build features on top of them.

  • Create the folder test/core/database/ in your project.
  • Create app_database_test.dart inside that folder with the following test:
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:field_inspector/core/database/app_database.dart';

void main() {
  // Use an in-memory database for fast, isolated tests
  late AppDatabase db;

  setUp(() {
    db = AppDatabase(NativeDatabase.memory());
  });

  tearDown(() async {
    await db.close();
  });

  test('insert and query inspection', () async {
    final now = DateTime.now();

    // Insert a test inspection using the generated companion
    await db.inspectionDao.insertInspection(
      InspectionsCompanion.insert(
        id: 'test-uuid-123',
        userId: 'user-1',
        title: 'Bridge Inspection',
        locationName: 'Main Street Bridge',
        category: 'Structural',
        createdAt: now,
        updatedAt: now,
      ),
    );

    // Query the inspection back by ID
    final result = await db.inspectionDao.getById('test-uuid-123');

    // Verify all fields match what was inserted
    expect(result.id, 'test-uuid-123');
    expect(result.userId, 'user-1');
    expect(result.title, 'Bridge Inspection');
    expect(result.locationName, 'Main Street Bridge');
    expect(result.category, 'Structural');
    expect(result.status, 'draft');
  });
}

What does this test do?

The test creates an in-memory database (no file on disk) so it runs fast and leaves no artifacts. It inserts one inspection, queries it back by ID, and asserts that all fields match the inserted values.

The status field should be 'draft' because that is the default defined in the table. This verifies that your withDefault clause works correctly.

  • Run the test by executing this command:
flutter test test/core/database/app_database_test.dart
  • You should see output showing 1 test passed with a green checkmark.

Test failing with type errors?

If you see errors about InspectionsCompanion or Inspection not being found, make sure you ran dart run build_runner build successfully before running the test. The companion classes are generated code that only exists after code generation completes.

help me fix failing Drift database tests

  • Set up build_runner watch for automatic code regeneration during development by running:
dart run build_runner watch --delete-conflicting-outputs

Keep build_runner watch running

Leave this command running in a separate terminal while you develop. It automatically regenerates code whenever you modify a table or DAO file. Press Ctrl+C to stop it when you are done for the day.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that build_runner completed without errors.

ⓧ I'd like to double check the full code

Here is the full app_database.dart file for comparison:

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

import 'tables/inspections_table.dart';
import 'tables/inspection_items_table.dart';
import 'tables/inspection_photos_table.dart';
import 'tables/inspection_signatures_table.dart';
import 'tables/sync_queue_table.dart';
import 'daos/inspection_dao.dart';
import 'daos/inspection_item_dao.dart';
import 'daos/inspection_photo_dao.dart';
import 'daos/signature_dao.dart';
import 'daos/sync_queue_dao.dart';

part 'app_database.g.dart';

// Central database class referencing all tables and DAOs
@DriftDatabase(
  tables: [
    Inspections,
    InspectionItems,
    InspectionPhotos,
    InspectionSignatures,
    SyncQueue,
  ],
  daos: [
    InspectionDao,
    InspectionItemDao,
    InspectionPhotoDao,
    SignatureDao,
    SyncQueueDao,
  ],
)
class AppDatabase extends _$AppDatabase {
  AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());

  @override
  int get schemaVersion => 1;

  static QueryExecutor _openConnection() {
    return driftDatabase(
      name: 'field_inspector_db',
      native: const DriftNativeOptions(
        databaseDirectory: getApplicationSupportDirectory,
      ),
    );
  }
}

Here is the full injection.dart file:

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../database/app_database.dart';

import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() => getIt.init();

@module
abstract class RegisterModule {
  @singleton
  SupabaseClient get supabaseClient => Supabase.instance.client;

  // Register AppDatabase as a singleton so all DAOs share one instance
  @singleton
  AppDatabase get appDatabase => AppDatabase();
}

Here is the full test file:

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:field_inspector/core/database/app_database.dart';

void main() {
  // Use an in-memory database for fast, isolated tests
  late AppDatabase db;

  setUp(() {
    db = AppDatabase(NativeDatabase.memory());
  });

  tearDown(() async {
    await db.close();
  });

  test('insert and query inspection', () async {
    final now = DateTime.now();

    // Insert a test inspection using the generated companion
    await db.inspectionDao.insertInspection(
      InspectionsCompanion.insert(
        id: 'test-uuid-123',
        userId: 'user-1',
        title: 'Bridge Inspection',
        locationName: 'Main Street Bridge',
        category: 'Structural',
        createdAt: now,
        updatedAt: now,
      ),
    );

    // Query the inspection back by ID
    final result = await db.inspectionDao.getById('test-uuid-123');

    // Verify all fields match what was inserted
    expect(result.id, 'test-uuid-123');
    expect(result.userId, 'user-1');
    expect(result.title, 'Bridge Inspection');
    expect(result.locationName, 'Main Street Bridge');
    expect(result.category, 'Structural');
    expect(result.status, 'draft');
  });
}

Your local database layer is in place. Every table is defined, every DAO is operational, and you have a passing test proving the insert-query cycle works. Next up, you will build domain models with Freezed and set up Either-based error handling to make your repositories bulletproof.

Build Domain Models and Error Handling

Your database layer stores raw rows in SQLite, but the rest of your app needs structured, immutable objects it can safely pass between layers.

In this step, you will create Freezed domain models that mirror your Drift tables, build a sealed error hierarchy so failures are explicit in function signatures, and define repository interfaces that form the contract between your domain and data layers.

In this step, get ready to:
  • Create five Freezed domain entity models with JSON serialization.
  • Build a sealed Failure class hierarchy and mapper classes.
  • Define abstract repository interfaces using Either-based return types.
Create Freezed domain entity models

Freezed generates immutable classes with built-in equality, copyWith, and JSON serialization. Each entity maps to one of your Drift tables but lives in the domain layer, decoupled from database internals.

  • Create the file lib/features/inspection/domain/entities/inspection_entity.dart and add the following code:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_entity.freezed.dart';
part 'inspection_entity.g.dart';

/// Represents a single field inspection record
@freezed
abstract class InspectionEntity with _$InspectionEntity {
  const factory InspectionEntity({
    required String id,
    required String userId,
    required String title,
    required String locationName,
    double? latitude,
    double? longitude,
    required String category,
    @Default('draft') String status,
    String? assetId,
    required DateTime createdAt,
    required DateTime updatedAt,
  }) = _InspectionEntity;

  factory InspectionEntity.fromJson(Map<String, dynamic> json) =>
      _$InspectionEntityFromJson(json);
}

What does this code do?

The @freezed annotation tells the code generator to produce an immutable class with ==, hashCode, toString, and copyWith.

The two part directives point at the generated files. The .freezed.dart file handles equality and copyWith. The .g.dart file handles JSON serialization via json_serializable.

  • Create lib/features/inspection/domain/entities/inspection_item_entity.dart with the following code:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_item_entity.freezed.dart';
part 'inspection_item_entity.g.dart';

/// A single checklist item within an inspection
@freezed
abstract class InspectionItemEntity with _$InspectionItemEntity {
  const factory InspectionItemEntity({
    required String id,
    required String inspectionId,
    required String description,
    @Default('low') String severity,
    @Default(false) bool isCompleted,
    String? notes,
    required DateTime createdAt,
  }) = _InspectionItemEntity;

  factory InspectionItemEntity.fromJson(Map<String, dynamic> json) =>
      _$InspectionItemEntityFromJson(json);
}
  • Create lib/features/inspection/domain/entities/inspection_photo_entity.dart with the following code:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_photo_entity.freezed.dart';
part 'inspection_photo_entity.g.dart';

/// A photo attached to an inspection checklist item
@freezed
abstract class InspectionPhotoEntity with _$InspectionPhotoEntity {
  const factory InspectionPhotoEntity({
    required String id,
    required String inspectionItemId,
    required String storagePath,
    String? caption,
    required DateTime createdAt,
  }) = _InspectionPhotoEntity;

  factory InspectionPhotoEntity.fromJson(Map<String, dynamic> json) =>
      _$InspectionPhotoEntityFromJson(json);
}
  • Create lib/features/signature/domain/entities/signature_entity.dart with the following code:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'signature_entity.freezed.dart';
part 'signature_entity.g.dart';

/// A digital signature collected for inspection sign-off
@freezed
abstract class SignatureEntity with _$SignatureEntity {
  const factory SignatureEntity({
    required String id,
    required String inspectionId,
    required String storagePath,
    required String signerName,
    required DateTime createdAt,
  }) = _SignatureEntity;

  factory SignatureEntity.fromJson(Map<String, dynamic> json) =>
      _$SignatureEntityFromJson(json);
}
  • Create lib/features/sync/domain/entities/sync_queue_entry.dart with the following code:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'sync_queue_entry.freezed.dart';
part 'sync_queue_entry.g.dart';

/// An entry in the offline sync queue
@freezed
abstract class SyncQueueEntry with _$SyncQueueEntry {
  const factory SyncQueueEntry({
    required int id,
    required String tableName,
    required String recordId,
    required String action,
    required String payload,
    required DateTime createdAt,
    DateTime? syncedAt,
    @Default(0) int retryCount,
  }) = _SyncQueueEntry;

  factory SyncQueueEntry.fromJson(Map<String, dynamic> json) =>
      _$SyncQueueEntryFromJson(json);
}

Why UUID string IDs?

Every entity uses a String ID that holds a UUID. This matches the Supabase schema where uuid is the primary key type. It also lets you generate IDs locally (offline) before syncing to the server.

The one exception is SyncQueueEntry which uses an auto-increment int ID because it is local-only and never syncs to Supabase.

  • Save all five entity files.

You won't see a change yet because Freezed needs code generation to produce the implementation classes.

Seeing red squiggles in VS Code?

That is expected. The part files do not exist yet. They will be generated in the final substep when you run build_runner. Ignore the errors for now.

Help me understand which errors are expected before Freezed code generation

Create the failure hierarchy and mapper classes

Instead of throwing exceptions, this project uses Either from fpdart to make errors explicit in function signatures. The Left side holds a typed Failure object, and the Right side holds the success value.

  • Create lib/core/error/failures.dart with the sealed failure hierarchy:
/// Base class for all domain-level failures
sealed class Failure {
  final String message;
  final StackTrace? stackTrace;

  const Failure({required this.message, this.stackTrace});
}

/// Failure communicating with Supabase or remote server
class ServerFailure extends Failure {
  const ServerFailure({required super.message, super.stackTrace});
}

/// Failure reading from or writing to local Drift database
class CacheFailure extends Failure {
  const CacheFailure({required super.message, super.stackTrace});
}

/// Failure due to no network connectivity
class NetworkFailure extends Failure {
  const NetworkFailure({required super.message, super.stackTrace});
}

/// Failure related to authentication (session expired, invalid credentials)
class AuthFailure extends Failure {
  const AuthFailure({required super.message, super.stackTrace});
}

/// Failure related to file storage (photo save, signature export)
class StorageFailure extends Failure {
  const StorageFailure({required super.message, super.stackTrace});
}

Why a sealed class?

Dart's sealed keyword means the compiler knows every possible subtype. When you pattern-match on a Failure, the compiler warns you if you forget to handle one of the five cases. This eliminates an entire category of bugs.

Next, you need a way to convert between Drift's generated row classes and your Freezed domain models. Mapper classes handle this translation.

  • Create lib/features/inspection/data/mappers/inspection_mapper.dart with the following code:
import 'package:drift/drift.dart';
import '../../../../core/database/app_database.dart';
import '../../domain/entities/inspection_entity.dart';

/// Converts between Drift table rows and domain entities
class InspectionMapper {
  /// Convert a Drift Inspection row to a domain entity
  InspectionEntity fromDrift(Inspection row) {
    return InspectionEntity(
      id: row.id,
      userId: row.userId,
      title: row.title,
      locationName: row.locationName,
      latitude: row.latitude,
      longitude: row.longitude,
      category: row.category,
      status: row.status,
      assetId: row.assetId,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
    );
  }

  /// Convert a domain entity to a Drift InspectionsCompanion for inserts/updates
  InspectionsCompanion toDrift(InspectionEntity entity) {
    return InspectionsCompanion(
      id: Value(entity.id),
      userId: Value(entity.userId),
      title: Value(entity.title),
      locationName: Value(entity.locationName),
      latitude: Value(entity.latitude),
      longitude: Value(entity.longitude),
      category: Value(entity.category),
      status: Value(entity.status),
      assetId: Value(entity.assetId),
      createdAt: Value(entity.createdAt),
      updatedAt: Value(entity.updatedAt),
    );
  }
}

What does this mapper do?

The fromDrift method takes a raw database row and produces a clean domain entity. The toDrift method wraps each field in Drift's Value() wrapper so the database knows which columns to write.

This keeps your domain layer completely independent of database implementation details. If you swapped Drift for another database library, only the mapper changes.

  • Save both failures.dart and inspection_mapper.dart.

You can verify the files exist by checking them in the VS Code Explorer sidebar. No runtime verification is possible yet because the Freezed models are not generated.

InspectionsCompanion not recognized?

The InspectionsCompanion class is generated by Drift. If you see errors on this import, make sure you ran build_runner successfully in the previous step. The generated app_database.g.dart file must exist.

Help me fix InspectionsCompanion import errors in my mapper

Define repository interfaces and generate code

Repository interfaces define the contract that your data layer must fulfill. They live in the domain layer so your business logic never depends on implementation details like Drift or Supabase.

  • Create lib/features/inspection/domain/repositories/inspection_repository.dart with the following code:
import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';
import '../entities/inspection_entity.dart';
import '../entities/inspection_item_entity.dart';
import '../entities/inspection_photo_entity.dart';

/// Contract for inspection data operations
abstract class InspectionRepository {
  /// Watch all inspections as a reactive stream
  Stream<Either<Failure, List<InspectionEntity>>> watchAllInspections();

  /// Get a single inspection by ID
  Future<Either<Failure, InspectionEntity>> getInspectionById(String id);

  /// Create a new inspection
  Future<Either<Failure, InspectionEntity>> createInspection(
      InspectionEntity entity);

  /// Update an existing inspection
  Future<Either<Failure, InspectionEntity>> updateInspection(
      InspectionEntity entity);

  /// Delete an inspection by ID
  Future<Either<Failure, void>> deleteInspection(String id);

  /// Add a checklist item to an inspection
  Future<Either<Failure, InspectionItemEntity>> addItem(
      InspectionItemEntity item);

  /// Add a photo to a checklist item
  Future<Either<Failure, InspectionPhotoEntity>> addPhoto(
      InspectionPhotoEntity photo);
}

Why Either instead of exceptions?

With Either<Failure, T>, every possible failure is visible in the function signature. The compiler forces callers to handle both outcomes. With exceptions, failures are invisible until they crash at runtime.

The Left side carries a typed Failure (network down, cache corrupted, auth expired). The Right side carries the success value. Pattern matching lets you handle each case explicitly.

  • Create lib/features/sync/domain/repositories/sync_repository.dart with the following code:
import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';
import '../entities/sync_queue_entry.dart';

/// Contract for sync queue operations
abstract class SyncRepository {
  /// Watch all pending (unsynced) queue entries
  Stream<Either<Failure, List<SyncQueueEntry>>> watchPendingEntries();

  /// Enqueue a new sync operation
  Future<Either<Failure, SyncQueueEntry>> enqueue(SyncQueueEntry entry);

  /// Mark an entry as synced with a timestamp
  Future<Either<Failure, void>> markSynced(int id);

  /// Increment retry count for a failed entry
  Future<Either<Failure, void>> incrementRetry(int id);

  /// Delete a synced entry from the queue
  Future<Either<Failure, void>> deleteEntry(int id);
}
  • Create lib/features/auth/domain/repositories/auth_repository.dart with the following code:
import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';

/// Contract for authentication operations
abstract class AuthRepository {
  /// Sign in with email and password
  Future<Either<Failure, String>> login({
    required String email,
    required String password,
  });

  /// Create a new account with email and password
  Future<Either<Failure, String>> signUp({
    required String email,
    required String password,
  });

  /// Sign out the current user
  Future<Either<Failure, void>> logout();

  /// Get the currently authenticated user ID, if any
  Future<Either<Failure, String?>> getCurrentUserId();
}
  • Create lib/features/signature/domain/repositories/signature_repository.dart with the following code:
import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';
import '../entities/signature_entity.dart';

/// Contract for digital signature operations
abstract class SignatureRepository {
  /// Watch signatures for a specific inspection
  Stream<Either<Failure, List<SignatureEntity>>> watchByInspection(
      String inspectionId);

  /// Save a new signature record
  Future<Either<Failure, SignatureEntity>> saveSignature(
      SignatureEntity signature);

  /// Delete a signature by ID
  Future<Either<Failure, void>> deleteSignature(String id);
}
  • Save all four repository files.

Now generate the Freezed implementation classes by running the code generator.

  • Run the code generator from your project root by executing this command:
flutter pub run build_runner build --delete-conflicting-outputs

What does this command do?

This invokes build_runner which scans for @freezed annotations and produces the .freezed.dart and .g.dart files. The --delete-conflicting-outputs flag removes stale generated files that might conflict with new ones.

  • Wait for the command to finish. You should see output indicating files were generated for each entity.
  • Verify that generated files now exist alongside your entity files (e.g. inspection_entity.freezed.dart and inspection_entity.g.dart).

All red squiggles in VS Code should disappear once the generated files exist.

Build runner shows errors?

Check that every entity file has both part directives. The part 'filename.freezed.dart' line must match the filename exactly. A typo in the part directive is the most common cause of generation failures.

Also confirm that freezed_annotation is in your dependencies (not dev_dependencies) and freezed is in your dev_dependencies.

Help me diagnose build_runner errors during Freezed code generation

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that no red squiggles remain in your entity or repository files.

ⓧ I'd like to double check the full code

Here is the complete failures.dart file:

/// Base class for all domain-level failures
sealed class Failure {
  final String message;
  final StackTrace? stackTrace;

  const Failure({required this.message, this.stackTrace});
}

/// Failure communicating with Supabase or remote server
class ServerFailure extends Failure {
  const ServerFailure({required super.message, super.stackTrace});
}

/// Failure reading from or writing to local Drift database
class CacheFailure extends Failure {
  const CacheFailure({required super.message, super.stackTrace});
}

/// Failure due to no network connectivity
class NetworkFailure extends Failure {
  const NetworkFailure({required super.message, super.stackTrace});
}

/// Failure related to authentication (session expired, invalid credentials)
class AuthFailure extends Failure {
  const AuthFailure({required super.message, super.stackTrace});
}

/// Failure related to file storage (photo save, signature export)
class StorageFailure extends Failure {
  const StorageFailure({required super.message, super.stackTrace});
}

Here is the complete inspection_entity.dart:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_entity.freezed.dart';
part 'inspection_entity.g.dart';

/// Represents a single field inspection record
@freezed
abstract class InspectionEntity with _$InspectionEntity {
  const factory InspectionEntity({
    required String id,
    required String userId,
    required String title,
    required String locationName,
    double? latitude,
    double? longitude,
    required String category,
    @Default('draft') String status,
    String? assetId,
    required DateTime createdAt,
    required DateTime updatedAt,
  }) = _InspectionEntity;

  factory InspectionEntity.fromJson(Map<String, dynamic> json) =>
      _$InspectionEntityFromJson(json);
}

Here is the complete inspection_item_entity.dart:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_item_entity.freezed.dart';
part 'inspection_item_entity.g.dart';

/// A single checklist item within an inspection
@freezed
abstract class InspectionItemEntity with _$InspectionItemEntity {
  const factory InspectionItemEntity({
    required String id,
    required String inspectionId,
    required String description,
    @Default('low') String severity,
    @Default(false) bool isCompleted,
    String? notes,
    required DateTime createdAt,
  }) = _InspectionItemEntity;

  factory InspectionItemEntity.fromJson(Map<String, dynamic> json) =>
      _$InspectionItemEntityFromJson(json);
}

Here is the complete inspection_photo_entity.dart:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_photo_entity.freezed.dart';
part 'inspection_photo_entity.g.dart';

/// A photo attached to an inspection checklist item
@freezed
abstract class InspectionPhotoEntity with _$InspectionPhotoEntity {
  const factory InspectionPhotoEntity({
    required String id,
    required String inspectionItemId,
    required String storagePath,
    String? caption,
    required DateTime createdAt,
  }) = _InspectionPhotoEntity;

  factory InspectionPhotoEntity.fromJson(Map<String, dynamic> json) =>
      _$InspectionPhotoEntityFromJson(json);
}

Here is the complete signature_entity.dart:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'signature_entity.freezed.dart';
part 'signature_entity.g.dart';

/// A digital signature collected for inspection sign-off
@freezed
abstract class SignatureEntity with _$SignatureEntity {
  const factory SignatureEntity({
    required String id,
    required String inspectionId,
    required String storagePath,
    required String signerName,
    required DateTime createdAt,
  }) = _SignatureEntity;

  factory SignatureEntity.fromJson(Map<String, dynamic> json) =>
      _$SignatureEntityFromJson(json);
}

Here is the complete sync_queue_entry.dart:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'sync_queue_entry.freezed.dart';
part 'sync_queue_entry.g.dart';

/// An entry in the offline sync queue
@freezed
abstract class SyncQueueEntry with _$SyncQueueEntry {
  const factory SyncQueueEntry({
    required int id,
    required String tableName,
    required String recordId,
    required String action,
    required String payload,
    required DateTime createdAt,
    DateTime? syncedAt,
    @Default(0) int retryCount,
  }) = _SyncQueueEntry;

  factory SyncQueueEntry.fromJson(Map<String, dynamic> json) =>
      _$SyncQueueEntryFromJson(json);
}

Here is the complete inspection_mapper.dart:

import 'package:drift/drift.dart';
import '../../../../core/database/app_database.dart';
import '../../domain/entities/inspection_entity.dart';

/// Converts between Drift table rows and domain entities
class InspectionMapper {
  /// Convert a Drift Inspection row to a domain entity
  InspectionEntity fromDrift(Inspection row) {
    return InspectionEntity(
      id: row.id,
      userId: row.userId,
      title: row.title,
      locationName: row.locationName,
      latitude: row.latitude,
      longitude: row.longitude,
      category: row.category,
      status: row.status,
      assetId: row.assetId,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
    );
  }

  /// Convert a domain entity to a Drift InspectionsCompanion for inserts/updates
  InspectionsCompanion toDrift(InspectionEntity entity) {
    return InspectionsCompanion(
      id: Value(entity.id),
      userId: Value(entity.userId),
      title: Value(entity.title),
      locationName: Value(entity.locationName),
      latitude: Value(entity.latitude),
      longitude: Value(entity.longitude),
      category: Value(entity.category),
      status: Value(entity.status),
      assetId: Value(entity.assetId),
      createdAt: Value(entity.createdAt),
      updatedAt: Value(entity.updatedAt),
    );
  }
}

Here is the complete inspection_repository.dart:

import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';
import '../entities/inspection_entity.dart';
import '../entities/inspection_item_entity.dart';
import '../entities/inspection_photo_entity.dart';

/// Contract for inspection data operations
abstract class InspectionRepository {
  /// Watch all inspections as a reactive stream
  Stream<Either<Failure, List<InspectionEntity>>> watchAllInspections();

  /// Get a single inspection by ID
  Future<Either<Failure, InspectionEntity>> getInspectionById(String id);

  /// Create a new inspection
  Future<Either<Failure, InspectionEntity>> createInspection(
      InspectionEntity entity);

  /// Update an existing inspection
  Future<Either<Failure, InspectionEntity>> updateInspection(
      InspectionEntity entity);

  /// Delete an inspection by ID
  Future<Either<Failure, void>> deleteInspection(String id);

  /// Add a checklist item to an inspection
  Future<Either<Failure, InspectionItemEntity>> addItem(
      InspectionItemEntity item);

  /// Add a photo to a checklist item
  Future<Either<Failure, InspectionPhotoEntity>> addPhoto(
      InspectionPhotoEntity photo);
}

Here is the complete sync_repository.dart:

import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';
import '../entities/sync_queue_entry.dart';

/// Contract for sync queue operations
abstract class SyncRepository {
  /// Watch all pending (unsynced) queue entries
  Stream<Either<Failure, List<SyncQueueEntry>>> watchPendingEntries();

  /// Enqueue a new sync operation
  Future<Either<Failure, SyncQueueEntry>> enqueue(SyncQueueEntry entry);

  /// Mark an entry as synced with a timestamp
  Future<Either<Failure, void>> markSynced(int id);

  /// Increment retry count for a failed entry
  Future<Either<Failure, void>> incrementRetry(int id);

  /// Delete a synced entry from the queue
  Future<Either<Failure, void>> deleteEntry(int id);
}

Here is the complete auth_repository.dart:

import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';

/// Contract for authentication operations
abstract class AuthRepository {
  /// Sign in with email and password
  Future<Either<Failure, String>> login({
    required String email,
    required String password,
  });

  /// Create a new account with email and password
  Future<Either<Failure, String>> signUp({
    required String email,
    required String password,
  });

  /// Sign out the current user
  Future<Either<Failure, void>> logout();

  /// Get the currently authenticated user ID, if any
  Future<Either<Failure, String?>> getCurrentUserId();
}

Here is the complete signature_repository.dart:

import 'package:fpdart/fpdart.dart';
import '../../../../core/error/failures.dart';
import '../entities/signature_entity.dart';

/// Contract for digital signature operations
abstract class SignatureRepository {
  /// Watch signatures for a specific inspection
  Stream<Either<Failure, List<SignatureEntity>>> watchByInspection(
      String inspectionId);

  /// Save a new signature record
  Future<Either<Failure, SignatureEntity>> saveSignature(
      SignatureEntity signature);

  /// Delete a signature by ID
  Future<Either<Failure, void>> deleteSignature(String id);
}

ⓧ I'd like to double check my folder structure

After this step, your project should have these new files:

lib/
├── core/
│   └── error/
│       └── failures.dart
└── features/
    ├── auth/
    │   └── domain/
    │       └── repositories/
    │           └── auth_repository.dart
    ├── inspection/
    │   ├── data/
    │   │   └── mappers/
    │   │       └── inspection_mapper.dart
    │   └── domain/
    │       ├── entities/
    │       │   ├── inspection_entity.dart
    │       │   ├── inspection_item_entity.dart
    │       │   └── inspection_photo_entity.dart
    │       └── repositories/
    │           └── inspection_repository.dart
    ├── signature/
    │   └── domain/
    │       ├── entities/
    │       │   └── signature_entity.dart
    │       └── repositories/
    │           └── signature_repository.dart
    └── sync/
        └── domain/
            ├── entities/
            │   └── sync_queue_entry.dart
            └── repositories/
                └── sync_repository.dart

Your domain layer is now fully defined. Next up, you will implement these repository interfaces with real Drift queries and sync queue logic.

Implement Repositories and Use Cases

Your domain layer now has interfaces, models, and a failure hierarchy. The missing piece is the concrete implementation that wires those abstractions to the database and the sync queue.

In this step you will build the repository implementation that wraps your DAOs, create thin use cases that expose single operations to the presentation layer, and write your first unit tests to verify the Either success and failure paths.

In this step, get ready to:
  • Implement InspectionRepositoryImpl with sync queue integration.
  • Create the abstract UseCase base class and seven concrete use cases.
  • Register everything in DI and write unit tests with mocktail.
Implement InspectionRepositoryImpl

The repository implementation is the bridge between your domain contracts and the Drift database. Every write method must also enqueue a sync entry so the sync engine can push changes to Supabase later.

  • Create the file lib/features/inspection/data/repositories/inspection_repository_impl.dart and add the following code:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';
import 'package:uuid/uuid.dart';

import '../../../../core/error/failures.dart';
import '../../domain/entities/inspection_entity.dart';
import '../../domain/entities/inspection_item_entity.dart';
import '../../domain/entities/inspection_photo_entity.dart';
import '../../domain/repositories/inspection_repository.dart';
import '../../../../core/database/app_database.dart';
import '../mappers/inspection_mapper.dart';

/// Concrete implementation of InspectionRepository.
/// Every write operation enqueues a SyncQueue entry for offline sync.
@LazySingleton(as: InspectionRepository)
class InspectionRepositoryImpl implements InspectionRepository {
  final InspectionDao _inspectionDao;
  final InspectionItemDao _inspectionItemDao;
  final InspectionPhotoDao _inspectionPhotoDao;
  final SyncQueueDao _syncQueueDao;
  final InspectionMapper _mapper;
  final _uuid = const Uuid();

  InspectionRepositoryImpl(
    this._inspectionDao,
    this._inspectionItemDao,
    this._inspectionPhotoDao,
    this._syncQueueDao,
    this._mapper,
  );

  /// Watch all inspections as a reactive stream
  @override
  Either<Failure, Stream<List<InspectionEntity>>> watchAllInspections() {
    try {
      final stream = _inspectionDao.watchAll().map(
            (rows) => rows.map(_mapper.fromDrift).toList(),
          );
      return Right(stream);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Get a single inspection by its ID
  @override
  Future<Either<Failure, InspectionEntity>> getInspectionById(
    String id,
  ) async {
    try {
      final row = await _inspectionDao.getById(id);
      if (row == null) {
        return Left(CacheFailure(message: 'Inspection not found'));
      }
      return Right(_mapper.fromDrift(row));
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Create a new inspection and enqueue a sync entry
  @override
  Future<Either<Failure, InspectionEntity>> createInspection(
    InspectionEntity entity,
  ) async {
    try {
      final companion = _mapper.toDrift(entity);
      await _inspectionDao.insertOne(companion);
      // Enqueue sync entry for later upload
      await _syncQueueDao.enqueue(
        tableName: 'inspections',
        recordId: entity.id,
        action: 'insert',
        payload: entity.toJson(),
      );
      return Right(entity);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Update an existing inspection and enqueue a sync entry
  @override
  Future<Either<Failure, InspectionEntity>> updateInspection(
    InspectionEntity entity,
  ) async {
    try {
      final companion = _mapper.toDrift(entity);
      await _inspectionDao.updateOne(companion);
      await _syncQueueDao.enqueue(
        tableName: 'inspections',
        recordId: entity.id,
        action: 'update',
        payload: entity.toJson(),
      );
      return Right(entity);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Delete an inspection and enqueue a sync entry
  @override
  Future<Either<Failure, Unit>> deleteInspection(String id) async {
    try {
      await _inspectionDao.deleteById(id);
      await _syncQueueDao.enqueue(
        tableName: 'inspections',
        recordId: id,
        action: 'delete',
        payload: {},
      );
      return const Right(unit);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Add a checklist item to an inspection
  @override
  Future<Either<Failure, InspectionItemEntity>> addItem(
    InspectionItemEntity item,
  ) async {
    try {
      await _inspectionItemDao.insertOne(item);
      await _syncQueueDao.enqueue(
        tableName: 'inspection_items',
        recordId: item.id,
        action: 'insert',
        payload: item.toJson(),
      );
      return Right(item);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Attach a photo to a checklist item
  @override
  Future<Either<Failure, InspectionPhotoEntity>> addPhoto(
    InspectionPhotoEntity photo,
  ) async {
    try {
      await _inspectionPhotoDao.insertOne(photo);
      await _syncQueueDao.enqueue(
        tableName: 'inspection_photos',
        recordId: photo.id,
        action: 'insert',
        payload: photo.toJson(),
      );
      return Right(photo);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }
}

What does this code do?

The @LazySingleton(as: InspectionRepository) annotation tells Injectable to register this class as the concrete implementation bound to the InspectionRepository interface. GetIt resolves the interface to this class throughout the app.

Every write method (create, update, delete, addItem, addPhoto) follows the same two-step pattern: persist to the local Drift database, then enqueue a SyncQueue entry with the table name, record ID, action type, and serialized payload. The sync engine processes these entries when connectivity returns.

Read operations (watchAllInspections, getInspectionById) only touch the local database. They wrap results in Either so callers handle success and failure uniformly without try-catch.

  • Save inspection_repository_impl.dart.
  • You will not see a visible change yet. The next substep creates use cases that consume this repository.

Seeing import errors?

Make sure the DAO class names (InspectionDao, InspectionItemDao, InspectionPhotoDao, SyncQueueDao) and the InspectionMapper class match the names you defined in Steps 5 and 6. The imports rely on exact filenames and export paths.

If the Failure types are unresolved, confirm your lib/core/error/failures.dart exports CacheFailure.

Help me fix unresolved imports in my InspectionRepositoryImpl.

Create the UseCase base class and concrete use cases

Use cases encapsulate a single business operation. Each one depends on a repository and returns Either<Failure, T>. An abstract base class enforces this contract across the entire app.

  • Create lib/core/usecases/use_case.dart and add the abstract base class:
import 'package:fpdart/fpdart.dart';

import '../error/failures.dart';

/// Abstract contract for all use cases in the app.
/// [Type] is the return type on success.
/// [Params] is the input parameter object.
abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

Why a base class for use cases?

The abstract UseCase<Type, Params> forces every use case to follow the same signature. BLoCs call use cases without knowing which repository sits behind them. This keeps presentation completely decoupled from data.

  • Create lib/features/inspection/domain/usecases/get_inspections.dart:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/usecases/use_case.dart';
import '../entities/inspection_entity.dart';
import '../repositories/inspection_repository.dart';

/// Retrieves all inspections as a reactive stream.
@injectable
class GetInspections implements UseCase<Stream<List<InspectionEntity>>, NoParams> {
  final InspectionRepository _repository;

  GetInspections(this._repository);

  @override
  Future<Either<Failure, Stream<List<InspectionEntity>>>> call(
    NoParams params,
  ) async {
    return _repository.watchAllInspections();
  }
}

/// Placeholder for use cases that take no parameters.
class NoParams {}
  • Create lib/features/inspection/domain/usecases/create_inspection.dart:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/usecases/use_case.dart';
import '../entities/inspection_entity.dart';
import '../repositories/inspection_repository.dart';

/// Creates a new inspection in the local database.
@injectable
class CreateInspection implements UseCase<InspectionEntity, InspectionEntity> {
  final InspectionRepository _repository;

  CreateInspection(this._repository);

  @override
  Future<Either<Failure, InspectionEntity>> call(
    InspectionEntity params,
  ) async {
    return _repository.createInspection(params);
  }
}
  • Create lib/features/inspection/domain/usecases/update_inspection.dart:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/usecases/use_case.dart';
import '../entities/inspection_entity.dart';
import '../repositories/inspection_repository.dart';

/// Updates an existing inspection record.
@injectable
class UpdateInspection implements UseCase<InspectionEntity, InspectionEntity> {
  final InspectionRepository _repository;

  UpdateInspection(this._repository);

  @override
  Future<Either<Failure, InspectionEntity>> call(
    InspectionEntity params,
  ) async {
    return _repository.updateInspection(params);
  }
}
  • Create lib/features/inspection/domain/usecases/delete_inspection.dart:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/usecases/use_case.dart';
import '../repositories/inspection_repository.dart';

/// Deletes an inspection by ID.
@injectable
class DeleteInspection implements UseCase<Unit, String> {
  final InspectionRepository _repository;

  DeleteInspection(this._repository);

  @override
  Future<Either<Failure, Unit>> call(String params) async {
    return _repository.deleteInspection(params);
  }
}
  • Create lib/features/inspection/domain/usecases/add_inspection_item.dart:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/usecases/use_case.dart';
import '../entities/inspection_item_entity.dart';
import '../repositories/inspection_repository.dart';

/// Adds a checklist item to an inspection.
@injectable
class AddInspectionItem
    implements UseCase<InspectionItemEntity, InspectionItemEntity> {
  final InspectionRepository _repository;

  AddInspectionItem(this._repository);

  @override
  Future<Either<Failure, InspectionItemEntity>> call(
    InspectionItemEntity params,
  ) async {
    return _repository.addItem(params);
  }
}
  • Create lib/features/inspection/domain/usecases/attach_photo.dart:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/usecases/use_case.dart';
import '../entities/inspection_photo_entity.dart';
import '../repositories/inspection_repository.dart';

/// Attaches a photo to a checklist item.
@injectable
class AttachPhoto
    implements UseCase<InspectionPhotoEntity, InspectionPhotoEntity> {
  final InspectionRepository _repository;

  AttachPhoto(this._repository);

  @override
  Future<Either<Failure, InspectionPhotoEntity>> call(
    InspectionPhotoEntity params,
  ) async {
    return _repository.addPhoto(params);
  }
}
  • Create lib/features/signature/domain/usecases/capture_signature.dart:
import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/usecases/use_case.dart';
import '../entities/signature_entity.dart';
import '../repositories/signature_repository.dart';

/// Captures and stores a digital signature for an inspection.
@injectable
class CaptureSignature implements UseCase<SignatureEntity, SignatureEntity> {
  final SignatureRepository _repository;

  CaptureSignature(this._repository);

  @override
  Future<Either<Failure, SignatureEntity>> call(
    SignatureEntity params,
  ) async {
    return _repository.saveSignature(params);
  }
}

What does each use case do?

Each use case is a single-purpose class that delegates to the repository. The @injectable annotation registers it as a factory in GetIt so BLoCs can request it through constructor injection.

DeleteInspection returns Either<Failure, Unit> because there is no meaningful value to return on success. Unit from fpdart replaces void in functional return types.

  • Save all seven use case files and the base class.
  • You will verify these compile correctly during the DI regeneration in the next substep.

UseCase import not resolving?

Confirm the base class lives at lib/core/usecases/use_case.dart. The import path is relative from each use case's location inside lib/features/inspection/domain/usecases/. Count the ../ segments carefully.

Help me fix my UseCase import paths.

Register in DI and write unit tests

The annotations are already in place. You need to regenerate the DI code, then write tests that verify your use cases return the correct Either values on both success and failure paths.

  • Regenerate the DI configuration by running:
flutter pub run build_runner build --delete-conflicting-outputs
  • You should see output confirming that the generated injection config includes InspectionRepositoryImpl, GetInspections, CreateInspection, and the other use cases.

What happened with DI?

The @LazySingleton(as: InspectionRepository) annotation registers the repository as a singleton bound to its abstract interface. The @injectable annotations on use cases register them as factories. Injectable generates all the registration code in your .config.dart file.

  • Create the test file test/features/inspection/domain/usecases/create_inspection_test.dart and add:
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';

import 'package:field_inspector/core/error/failures.dart';
import 'package:field_inspector/features/inspection/domain/entities/inspection_entity.dart';
import 'package:field_inspector/features/inspection/domain/repositories/inspection_repository.dart';
import 'package:field_inspector/features/inspection/domain/usecases/create_inspection.dart';

// Mock the repository interface
class MockInspectionRepository extends Mock implements InspectionRepository {}

void main() {
  late CreateInspection useCase;
  late MockInspectionRepository mockRepository;

  setUp(() {
    mockRepository = MockInspectionRepository();
    useCase = CreateInspection(mockRepository);
  });

  final tInspection = InspectionEntity(
    id: 'test-id-123',
    userId: 'user-1',
    title: 'Roof Inspection',
    locationName: 'Building A',
    latitude: 40.7128,
    longitude: -74.0060,
    category: 'Structural',
    status: 'draft',
    assetId: null,
    createdAt: DateTime(2026, 5, 18),
    updatedAt: DateTime(2026, 5, 18),
  );

  group('CreateInspection', () {
    test('should return InspectionEntity on success', () async {
      // Arrange
      when(() => mockRepository.createInspection(tInspection))
          .thenAnswer((_) async => Right(tInspection));

      // Act
      final result = await useCase(tInspection);

      // Assert
      expect(result, Right(tInspection));
      verify(() => mockRepository.createInspection(tInspection)).called(1);
      verifyNoMoreInteractions(mockRepository);
    });

    test('should return CacheFailure when repository fails', () async {
      // Arrange
      final tFailure = CacheFailure(message: 'Database write failed');
      when(() => mockRepository.createInspection(tInspection))
          .thenAnswer((_) async => Left(tFailure));

      // Act
      final result = await useCase(tInspection);

      // Assert
      expect(result, Left(tFailure));
      verify(() => mockRepository.createInspection(tInspection)).called(1);
    });
  });
}

What does this test do?

The test mocks InspectionRepository using mocktail. The success test stubs createInspection to return Right(entity), calls the use case, and asserts the result is a Right with the same entity. The failure test stubs with Left(CacheFailure) and asserts the use case propagates it unchanged.

This pattern proves that use cases are transparent wrappers. If the repository returns Left, the use case returns Left. No swallowed errors.

  • Create test/features/inspection/domain/usecases/get_inspections_test.dart and add:
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';

import 'package:field_inspector/core/error/failures.dart';
import 'package:field_inspector/features/inspection/domain/entities/inspection_entity.dart';
import 'package:field_inspector/features/inspection/domain/repositories/inspection_repository.dart';
import 'package:field_inspector/features/inspection/domain/usecases/get_inspections.dart';

class MockInspectionRepository extends Mock implements InspectionRepository {}

void main() {
  late GetInspections useCase;
  late MockInspectionRepository mockRepository;

  setUp(() {
    mockRepository = MockInspectionRepository();
    useCase = GetInspections(mockRepository);
  });

  group('GetInspections', () {
    test('should return stream of inspections on success', () async {
      // Arrange
      final tStream = Stream<List<InspectionEntity>>.value([]);
      when(() => mockRepository.watchAllInspections())
          .thenReturn(Right(tStream));

      // Act
      final result = await useCase(NoParams());

      // Assert
      expect(result.isRight(), true);
      verify(() => mockRepository.watchAllInspections()).called(1);
    });

    test('should return CacheFailure when repository fails', () async {
      // Arrange
      final tFailure = CacheFailure(message: 'Database read failed');
      when(() => mockRepository.watchAllInspections())
          .thenReturn(Left(tFailure));

      // Act
      final result = await useCase(NoParams());

      // Assert
      expect(result.isLeft(), true);
      result.match(
        (failure) => expect(failure, tFailure),
        (_) => fail('Expected Left'),
      );
    });
  });
}
  • Save both test files.
  • Run the tests by executing:
flutter test test/features/inspection/domain/usecases/
  • You should see all 4 tests passing (2 per file).

Tests failing with type errors?

If you see errors about InspectionEntity constructors, make sure your Freezed model's required parameters match the test fixture. Run flutter pub run build_runner build if you changed any Freezed models since last generation.

If mocktail throws a MissingStubError, confirm the when() stub matches the exact method signature and parameter you pass in the test.

Help me debug my failing use case tests.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that build_runner generated without errors.

ⓧ I'd like to double check the full code

Here is the abstract base class:

import 'package:fpdart/fpdart.dart';

import '../error/failures.dart';

/// Abstract contract for all use cases in the app.
/// [Type] is the return type on success.
/// [Params] is the input parameter object.
abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

Here is the full repository implementation:

import 'package:fpdart/fpdart.dart';
import 'package:injectable/injectable.dart';
import 'package:uuid/uuid.dart';

import '../../../../core/error/failures.dart';
import '../../domain/entities/inspection_entity.dart';
import '../../domain/entities/inspection_item_entity.dart';
import '../../domain/entities/inspection_photo_entity.dart';
import '../../domain/repositories/inspection_repository.dart';
import '../../../../core/database/app_database.dart';
import '../mappers/inspection_mapper.dart';

/// Concrete implementation of InspectionRepository.
/// Every write operation enqueues a SyncQueue entry for offline sync.
@LazySingleton(as: InspectionRepository)
class InspectionRepositoryImpl implements InspectionRepository {
  final InspectionDao _inspectionDao;
  final InspectionItemDao _inspectionItemDao;
  final InspectionPhotoDao _inspectionPhotoDao;
  final SyncQueueDao _syncQueueDao;
  final InspectionMapper _mapper;
  final _uuid = const Uuid();

  InspectionRepositoryImpl(
    this._inspectionDao,
    this._inspectionItemDao,
    this._inspectionPhotoDao,
    this._syncQueueDao,
    this._mapper,
  );

  /// Watch all inspections as a reactive stream
  @override
  Either<Failure, Stream<List<InspectionEntity>>> watchAllInspections() {
    try {
      final stream = _inspectionDao.watchAll().map(
            (rows) => rows.map(_mapper.fromDrift).toList(),
          );
      return Right(stream);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Get a single inspection by its ID
  @override
  Future<Either<Failure, InspectionEntity>> getInspectionById(
    String id,
  ) async {
    try {
      final row = await _inspectionDao.getById(id);
      if (row == null) {
        return Left(CacheFailure(message: 'Inspection not found'));
      }
      return Right(_mapper.fromDrift(row));
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Create a new inspection and enqueue a sync entry
  @override
  Future<Either<Failure, InspectionEntity>> createInspection(
    InspectionEntity entity,
  ) async {
    try {
      final companion = _mapper.toDrift(entity);
      await _inspectionDao.insertOne(companion);
      // Enqueue sync entry for later upload
      await _syncQueueDao.enqueue(
        tableName: 'inspections',
        recordId: entity.id,
        action: 'insert',
        payload: entity.toJson(),
      );
      return Right(entity);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Update an existing inspection and enqueue a sync entry
  @override
  Future<Either<Failure, InspectionEntity>> updateInspection(
    InspectionEntity entity,
  ) async {
    try {
      final companion = _mapper.toDrift(entity);
      await _inspectionDao.updateOne(companion);
      await _syncQueueDao.enqueue(
        tableName: 'inspections',
        recordId: entity.id,
        action: 'update',
        payload: entity.toJson(),
      );
      return Right(entity);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Delete an inspection and enqueue a sync entry
  @override
  Future<Either<Failure, Unit>> deleteInspection(String id) async {
    try {
      await _inspectionDao.deleteById(id);
      await _syncQueueDao.enqueue(
        tableName: 'inspections',
        recordId: id,
        action: 'delete',
        payload: {},
      );
      return const Right(unit);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Add a checklist item to an inspection
  @override
  Future<Either<Failure, InspectionItemEntity>> addItem(
    InspectionItemEntity item,
  ) async {
    try {
      await _inspectionItemDao.insertOne(item);
      await _syncQueueDao.enqueue(
        tableName: 'inspection_items',
        recordId: item.id,
        action: 'insert',
        payload: item.toJson(),
      );
      return Right(item);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }

  /// Attach a photo to a checklist item
  @override
  Future<Either<Failure, InspectionPhotoEntity>> addPhoto(
    InspectionPhotoEntity photo,
  ) async {
    try {
      await _inspectionPhotoDao.insertOne(photo);
      await _syncQueueDao.enqueue(
        tableName: 'inspection_photos',
        recordId: photo.id,
        action: 'insert',
        payload: photo.toJson(),
      );
      return Right(photo);
    } catch (e) {
      return Left(CacheFailure(message: e.toString()));
    }
  }
}

Your data layer is now fully wired. The repository writes locally and queues everything for sync. The use cases give your BLoCs a clean, testable interface. Next up, you will build the inspection list screen that consumes these use cases through a BLoC.

Build the Inspection List Screen

Your repositories and use cases are tested and wired into the DI container. The data layer can read, write, and stream inspections from the local database.

Now you need a way for the user to actually see their inspections. In this step, you will build the presentation layer for the inspection list using the BLoC pattern. The BLoC subscribes to the reactive stream from your repository, so the UI updates automatically whenever data changes.

In this step, get ready to:
  • Create the InspectionListBloc with Freezed events and states.
  • Build the InspectionListPage with a reactive ListView, skeleton loading, and swipe-to-delete.
  • Write BLoC tests verifying event-to-state transitions.
Create the InspectionListBloc

A BLoC (Business Logic Component) sits between your use cases and your UI. It receives events (user actions or system triggers), processes them through use cases, and emits states that the UI renders.

  • Create the file lib/features/inspection/presentation/bloc/inspection_list_bloc.dart and add the following code:
import 'dart:async';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';

import '../../domain/entities/inspection_entity.dart';
import '../../domain/repositories/inspection_repository.dart';
import '../../domain/usecases/delete_inspection.dart' as delete_uc;
import '../../domain/usecases/get_inspections.dart';

part 'inspection_list_bloc.freezed.dart';

// --- Events: actions the UI can trigger ---
@freezed
class InspectionListEvent with _$InspectionListEvent {
  const factory InspectionListEvent.loadInspections() = LoadInspections;
  const factory InspectionListEvent.deleteInspection(String id) =
      DeleteInspection;
  const factory InspectionListEvent.filterInspections(String? category) =
      FilterInspections;
}

// --- States: what the UI renders ---
@freezed
class InspectionListState with _$InspectionListState {
  const factory InspectionListState.initial() = InspectionListInitial;
  const factory InspectionListState.loading() = InspectionListLoading;
  const factory InspectionListState.loaded(
    List<InspectionEntity> inspections,
  ) = InspectionListLoaded;
  const factory InspectionListState.error(String message) =
      InspectionListError;
}

// --- BLoC: glues events to states via use cases ---
@injectable
class InspectionListBloc
    extends Bloc<InspectionListEvent, InspectionListState> {
  final GetInspections _getInspections;
  final delete_uc.DeleteInspection _deleteInspection;

  InspectionListBloc(
    this._getInspections,
    this._deleteInspection,
  ) : super(const InspectionListState.initial()) {
    on<LoadInspections>(_onLoadInspections);
    on<DeleteInspection>(_onDeleteInspection);
    on<FilterInspections>(_onFilterInspections);
  }

  Future<void> _onLoadInspections(
    LoadInspections event,
    Emitter<InspectionListState> emit,
  ) async {
    emit(const InspectionListState.loading());

    // Get the stream from the repository via the use case
    final result = await _getInspections(NoParams());

    await result.fold(
      (failure) async =>
          emit(InspectionListState.error(failure.message)),
      (stream) async {
        // emit.forEach subscribes to the stream and emits
        // a new state for every data event
        await emit.forEach<List<InspectionEntity>>(
          stream,
          onData: (inspections) =>
              InspectionListState.loaded(inspections),
          onError: (error, _) =>
              InspectionListState.error(error.toString()),
        );
      },
    );
  }

  Future<void> _onDeleteInspection(
    DeleteInspection event,
    Emitter<InspectionListState> emit,
  ) async {
    // Call delete use case; the stream auto-updates the list
    final result = await _deleteInspection(event.id);
    result.fold(
      (failure) => emit(InspectionListState.error(failure.message)),
      (_) => null,
    );
  }

  void _onFilterInspections(
    FilterInspections event,
    Emitter<InspectionListState> emit,
  ) {
    final currentState = state;
    if (currentState is InspectionListLoaded) {
      if (event.category == null) {
        add(const InspectionListEvent.loadInspections());
      } else {
        final filtered = currentState.inspections
            .where((i) => i.category == event.category)
            .toList();
        emit(InspectionListState.loaded(filtered));
      }
    }
  }
}

What does this code do?

The file defines three things in one place: events, states, and the BLoC itself. Freezed generates the sealed union classes so you get exhaustive pattern matching for free.

  • Events are the inputs: load all inspections, delete one, or filter by category.
  • States are the outputs: initial, loading, loaded with a list, or error with a message.
  • The emit.forEach call subscribes to the database stream. Every time the database emits a new list, the BLoC emits a new InspectionListLoaded state and the UI rebuilds.
  • The delete handler delegates to the use case. Because the list comes from a stream, the deletion propagates automatically without a manual reload.
  • Run code generation to produce the Freezed output:
flutter pub run build_runner build --delete-conflicting-outputs
  • Confirm the file inspection_list_bloc.freezed.dart appears in the same directory.

Build runner failing?

If you see errors about unresolved imports, check that InspectionEntity and GetInspections exist with the exact import paths shown above. Also verify your NoParams class is defined in the use case base class file.

help me debug my build_runner errors for the InspectionListBloc

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import 'dart:async';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';

import '../../domain/entities/inspection_entity.dart';
import '../../domain/repositories/inspection_repository.dart';
import '../../domain/usecases/delete_inspection.dart' as delete_uc;
import '../../domain/usecases/get_inspections.dart';

part 'inspection_list_bloc.freezed.dart';

// --- Events: actions the UI can trigger ---
@freezed
class InspectionListEvent with _$InspectionListEvent {
  const factory InspectionListEvent.loadInspections() = LoadInspections;
  const factory InspectionListEvent.deleteInspection(String id) =
      DeleteInspection;
  const factory InspectionListEvent.filterInspections(String? category) =
      FilterInspections;
}

// --- States: what the UI renders ---
@freezed
class InspectionListState with _$InspectionListState {
  const factory InspectionListState.initial() = InspectionListInitial;
  const factory InspectionListState.loading() = InspectionListLoading;
  const factory InspectionListState.loaded(
    List<InspectionEntity> inspections,
  ) = InspectionListLoaded;
  const factory InspectionListState.error(String message) =
      InspectionListError;
}

// --- BLoC: glues events to states via use cases ---
@injectable
class InspectionListBloc
    extends Bloc<InspectionListEvent, InspectionListState> {
  final GetInspections _getInspections;
  final delete_uc.DeleteInspection _deleteInspection;

  InspectionListBloc(
    this._getInspections,
    this._deleteInspection,
  ) : super(const InspectionListState.initial()) {
    on<LoadInspections>(_onLoadInspections);
    on<DeleteInspection>(_onDeleteInspection);
    on<FilterInspections>(_onFilterInspections);
  }

  Future<void> _onLoadInspections(
    LoadInspections event,
    Emitter<InspectionListState> emit,
  ) async {
    emit(const InspectionListState.loading());

    // Get the stream from the repository via the use case
    final result = await _getInspections(NoParams());

    await result.fold(
      (failure) async =>
          emit(InspectionListState.error(failure.message)),
      (stream) async {
        // emit.forEach subscribes to the stream and emits
        // a new state for every data event
        await emit.forEach<List<InspectionEntity>>(
          stream,
          onData: (inspections) =>
              InspectionListState.loaded(inspections),
          onError: (error, _) =>
              InspectionListState.error(error.toString()),
        );
      },
    );
  }

  Future<void> _onDeleteInspection(
    DeleteInspection event,
    Emitter<InspectionListState> emit,
  ) async {
    // Call delete use case; the stream auto-updates the list
    final result = await _deleteInspection(event.id);
    result.fold(
      (failure) => emit(InspectionListState.error(failure.message)),
      (_) => null,
    );
  }

  void _onFilterInspections(
    FilterInspections event,
    Emitter<InspectionListState> emit,
  ) {
    final currentState = state;
    if (currentState is InspectionListLoaded) {
      if (event.category == null) {
        add(const InspectionListEvent.loadInspections());
      } else {
        final filtered = currentState.inspections
            .where((i) => i.category == event.category)
            .toList();
        emit(InspectionListState.loaded(filtered));
      }
    }
  }
}
Build the InspectionListPage

The page widget connects the BLoC to the screen. It provides the BLoC via BlocProvider, listens to state changes with BlocBuilder, and renders a different widget tree for each state.

  • Create the file lib/features/inspection/presentation/pages/inspection_list_page.dart and add the page scaffold with skeleton loading:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shimmer/shimmer.dart';

import '../../../../core/di/injection.dart';
import '../../domain/entities/inspection_entity.dart';
import '../bloc/inspection_list_bloc.dart';

/// Entry point widget that provides the BLoC to the tree
class InspectionListPage extends StatelessWidget {
  const InspectionListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // GetIt resolves the bloc with its use case dependencies
      create: (_) => getIt<InspectionListBloc>()
        ..add(const InspectionListEvent.loadInspections()),
      child: const _InspectionListView(),
    );
  }
}

/// Internal widget that builds UI based on BLoC state
class _InspectionListView extends StatelessWidget {
  const _InspectionListView();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Inspections'),
      ),
      body: BlocBuilder<InspectionListBloc, InspectionListState>(
        builder: (context, state) {
          return state.when(
            initial: () => const SizedBox.shrink(),
            loading: () => _buildSkeletonLoading(),
            loaded: (inspections) =>
                _buildInspectionList(context, inspections),
            error: (message) => Center(
              child: Text(message),
            ),
          );
        },
      ),
      // FAB for creating a new inspection
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Navigation to form screen added in next step
        },
        child: const Icon(Icons.add),
      ),
    );
  }

  /// Shimmer skeleton shown while data loads
  Widget _buildSkeletonLoading() {
    return ListView.builder(
      itemCount: 5,
      padding: const EdgeInsets.all(16),
      itemBuilder: (context, index) {
        return Shimmer.fromColors(
          baseColor: Colors.grey[300]!,
          highlightColor: Colors.grey[100]!,
          child: Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Container(
                    height: 16,
                    width: 200,
                    color: Colors.white,
                  ),
                  const SizedBox(height: 8),
                  Container(
                    height: 12,
                    width: 150,
                    color: Colors.white,
                  ),
                  const SizedBox(height: 8),
                  Container(
                    height: 12,
                    width: 100,
                    color: Colors.white,
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }

  /// Builds the scrollable list with pull-to-refresh
  Widget _buildInspectionList(
    BuildContext context,
    List<InspectionEntity> inspections,
  ) {
    if (inspections.isEmpty) {
      return const Center(
        child: Text('No inspections yet. Tap + to create one.'),
      );
    }

    return RefreshIndicator(
      onRefresh: () async {
        context
            .read<InspectionListBloc>()
            .add(const InspectionListEvent.loadInspections());
      },
      child: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: inspections.length,
        itemBuilder: (context, index) {
          final inspection = inspections[index];
          return _buildInspectionCard(context, inspection);
        },
      ),
    );
  }

  /// Individual card with swipe-to-delete and undo snackbar
  Widget _buildInspectionCard(
    BuildContext context,
    InspectionEntity inspection,
  ) {
    return Dismissible(
      key: Key(inspection.id),
      direction: DismissDirection.endToStart,
      background: Container(
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        color: Colors.red,
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      onDismissed: (_) {
        // Dispatch delete event to the BLoC
        context
            .read<InspectionListBloc>()
            .add(InspectionListEvent.deleteInspection(inspection.id));

        // Show undo snackbar
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('${inspection.title} deleted'),
            action: SnackBarAction(
              label: 'Undo',
              onPressed: () {
                // Re-create logic handled by repository
              },
            ),
          ),
        );
      },
      child: Card(
        margin: const EdgeInsets.only(bottom: 12),
        child: ListTile(
          // Inspection title
          title: Text(inspection.title),
          // Location name
          subtitle: Text(inspection.locationName ?? ''),
          trailing: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // Sync status icon
              Icon(
                inspection.syncedAt != null
                    ? Icons.cloud_done
                    : Icons.cloud_off,
                size: 20,
                color: inspection.syncedAt != null
                    ? Colors.green
                    : Colors.orange,
              ),
              const SizedBox(height: 4),
              // Category badge
              Container(
                padding: const EdgeInsets.symmetric(
                  horizontal: 8,
                  vertical: 2,
                ),
                decoration: BoxDecoration(
                  color:
                      Theme.of(context).colorScheme.primaryContainer,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Text(
                  inspection.category ?? '',
                  style: Theme.of(context).textTheme.labelSmall,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

What does this code do?

The page has two classes working together. InspectionListPage creates and provides the BLoC, immediately dispatching a LoadInspections event. _InspectionListView renders the current state.

  • Shimmer.fromColors wraps placeholder cards while the database stream loads. The baseColor and highlightColor create the animated pulse effect.
  • RefreshIndicator adds pull-to-refresh. Pulling down re-dispatches the load event.
  • Dismissible enables swipe-to-delete. On dismiss, the BLoC fires the delete event and a SnackBar with an Undo action appears.
  • Each card shows the title, location, a sync status icon (green cloud for synced, orange for pending), and a category badge styled with the theme's primary container color.
  • Save inspection_list_page.dart.
  • Run the app on your emulator. You should see the Inspections app bar, an empty state message, and the floating action button in the bottom right corner.

Seeing a blank screen or red error?

Make sure InspectionListBloc is registered in your DI container. The @injectable annotation on the BLoC class needs a fresh build_runner build to generate the registration. Also confirm that your getIt instance is the one exported from core/di/injection.dart.

help me debug why my InspectionListPage shows a red error screen

✔️ Awesome, I've got everything!

Great. Make sure you have 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:shimmer/shimmer.dart';

import '../../../../core/di/injection.dart';
import '../../domain/entities/inspection_entity.dart';
import '../bloc/inspection_list_bloc.dart';

/// Entry point widget that provides the BLoC to the tree
class InspectionListPage extends StatelessWidget {
  const InspectionListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // GetIt resolves the bloc with its use case dependencies
      create: (_) => getIt<InspectionListBloc>()
        ..add(const InspectionListEvent.loadInspections()),
      child: const _InspectionListView(),
    );
  }
}

/// Internal widget that builds UI based on BLoC state
class _InspectionListView extends StatelessWidget {
  const _InspectionListView();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Inspections'),
      ),
      body: BlocBuilder<InspectionListBloc, InspectionListState>(
        builder: (context, state) {
          return state.when(
            initial: () => const SizedBox.shrink(),
            loading: () => _buildSkeletonLoading(),
            loaded: (inspections) =>
                _buildInspectionList(context, inspections),
            error: (message) => Center(
              child: Text(message),
            ),
          );
        },
      ),
      // FAB for creating a new inspection
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Navigation to form screen added in next step
        },
        child: const Icon(Icons.add),
      ),
    );
  }

  /// Shimmer skeleton shown while data loads
  Widget _buildSkeletonLoading() {
    return ListView.builder(
      itemCount: 5,
      padding: const EdgeInsets.all(16),
      itemBuilder: (context, index) {
        return Shimmer.fromColors(
          baseColor: Colors.grey[300]!,
          highlightColor: Colors.grey[100]!,
          child: Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Container(
                    height: 16,
                    width: 200,
                    color: Colors.white,
                  ),
                  const SizedBox(height: 8),
                  Container(
                    height: 12,
                    width: 150,
                    color: Colors.white,
                  ),
                  const SizedBox(height: 8),
                  Container(
                    height: 12,
                    width: 100,
                    color: Colors.white,
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }

  /// Builds the scrollable list with pull-to-refresh
  Widget _buildInspectionList(
    BuildContext context,
    List<InspectionEntity> inspections,
  ) {
    if (inspections.isEmpty) {
      return const Center(
        child: Text('No inspections yet. Tap + to create one.'),
      );
    }

    return RefreshIndicator(
      onRefresh: () async {
        context
            .read<InspectionListBloc>()
            .add(const InspectionListEvent.loadInspections());
      },
      child: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: inspections.length,
        itemBuilder: (context, index) {
          final inspection = inspections[index];
          return _buildInspectionCard(context, inspection);
        },
      ),
    );
  }

  /// Individual card with swipe-to-delete and undo snackbar
  Widget _buildInspectionCard(
    BuildContext context,
    InspectionEntity inspection,
  ) {
    return Dismissible(
      key: Key(inspection.id),
      direction: DismissDirection.endToStart,
      background: Container(
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        color: Colors.red,
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      onDismissed: (_) {
        // Dispatch delete event to the BLoC
        context
            .read<InspectionListBloc>()
            .add(InspectionListEvent.deleteInspection(inspection.id));

        // Show undo snackbar
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('${inspection.title} deleted'),
            action: SnackBarAction(
              label: 'Undo',
              onPressed: () {
                // Re-create logic handled by repository
              },
            ),
          ),
        );
      },
      child: Card(
        margin: const EdgeInsets.only(bottom: 12),
        child: ListTile(
          // Inspection title
          title: Text(inspection.title),
          // Location name
          subtitle: Text(inspection.locationName ?? ''),
          trailing: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // Sync status icon
              Icon(
                inspection.syncedAt != null
                    ? Icons.cloud_done
                    : Icons.cloud_off,
                size: 20,
                color: inspection.syncedAt != null
                    ? Colors.green
                    : Colors.orange,
              ),
              const SizedBox(height: 4),
              // Category badge
              Container(
                padding: const EdgeInsets.symmetric(
                  horizontal: 8,
                  vertical: 2,
                ),
                decoration: BoxDecoration(
                  color:
                      Theme.of(context).colorScheme.primaryContainer,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Text(
                  inspection.category ?? '',
                  style: Theme.of(context).textTheme.labelSmall,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
Write BLoC tests

The BLoC pattern's biggest advantage is testability. You can verify every event-to-state transition in isolation, without needing a running app or emulator.

  • Create the file test/features/inspection/presentation/bloc/inspection_list_bloc_test.dart and add the following test suite:
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';

import 'package:field_inspector/core/error/failures.dart';
import 'package:field_inspector/features/inspection/domain/entities/inspection_entity.dart';
import 'package:field_inspector/features/inspection/domain/usecases/delete_inspection.dart'
    as delete_uc;
import 'package:field_inspector/features/inspection/domain/usecases/get_inspections.dart';
import 'package:field_inspector/features/inspection/presentation/bloc/inspection_list_bloc.dart';

// Mock use cases with mocktail
class MockGetInspections extends Mock implements GetInspections {}

class MockDeleteInspection extends Mock
    implements delete_uc.DeleteInspection {}

void main() {
  late InspectionListBloc bloc;
  late MockGetInspections mockGetInspections;
  late MockDeleteInspection mockDeleteInspection;

  setUp(() {
    mockGetInspections = MockGetInspections();
    mockDeleteInspection = MockDeleteInspection();
    bloc = InspectionListBloc(mockGetInspections, mockDeleteInspection);
  });

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

  // Test data
  final testInspections = [
    InspectionEntity(
      id: '1',
      userId: 'user-1',
      title: 'Bridge Check',
      category: 'Structural',
      status: 'draft',
      createdAt: DateTime(2025, 1, 1),
      updatedAt: DateTime(2025, 1, 1),
    ),
  ];

  group('LoadInspections', () {
    blocTest<InspectionListBloc, InspectionListState>(
      'emits [loading, loaded] when stream returns data',
      build: () {
        when(() => mockGetInspections(any())).thenAnswer(
          (_) async => Right(Stream.value(testInspections)),
        );
        return bloc;
      },
      act: (bloc) =>
          bloc.add(const InspectionListEvent.loadInspections()),
      expect: () => [
        const InspectionListState.loading(),
        InspectionListState.loaded(testInspections),
      ],
    );

    blocTest<InspectionListBloc, InspectionListState>(
      'emits [loading, error] when use case returns failure',
      build: () {
        when(() => mockGetInspections(any())).thenAnswer(
          (_) async => const Left(CacheFailure('Database error')),
        );
        return bloc;
      },
      act: (bloc) =>
          bloc.add(const InspectionListEvent.loadInspections()),
      expect: () => [
        const InspectionListState.loading(),
        const InspectionListState.error('Database error'),
      ],
    );

    blocTest<InspectionListBloc, InspectionListState>(
      'stream updates propagate as new loaded states',
      build: () {
        // Stream emits twice: initial list, then updated list
        final updatedList = [
          ...testInspections,
          InspectionEntity(
            id: '2',
            userId: 'user-1',
            title: 'Roof Inspection',
            category: 'General',
            status: 'draft',
            createdAt: DateTime(2025, 1, 2),
            updatedAt: DateTime(2025, 1, 2),
          ),
        ];
        when(() => mockGetInspections(any())).thenAnswer(
          (_) async => Right(
            Stream.fromIterable([testInspections, updatedList]),
          ),
        );
        return bloc;
      },
      act: (bloc) =>
          bloc.add(const InspectionListEvent.loadInspections()),
      expect: () => [
        const InspectionListState.loading(),
        InspectionListState.loaded(testInspections),
        // Second emission from the stream
        isA<InspectionListLoaded>(),
      ],
    );
  });

  group('DeleteInspection', () {
    blocTest<InspectionListBloc, InspectionListState>(
      'calls delete use case with correct id',
      build: () {
        when(() => mockDeleteInspection(any())).thenAnswer(
          (_) async => const Right(unit),
        );
        return bloc;
      },
      act: (bloc) => bloc
          .add(const InspectionListEvent.deleteInspection('1')),
      verify: (_) {
        verify(() => mockDeleteInspection('1')).called(1);
      },
    );

    blocTest<InspectionListBloc, InspectionListState>(
      'emits error state when delete fails',
      build: () {
        when(() => mockDeleteInspection(any())).thenAnswer(
          (_) async =>
              const Left(ServerFailure('Delete failed')),
        );
        return bloc;
      },
      act: (bloc) => bloc
          .add(const InspectionListEvent.deleteInspection('1')),
      expect: () => [
        const InspectionListState.error('Delete failed'),
      ],
    );
  });
}

What does this test file cover?

  • LoadInspections success: verifies the BLoC emits loading then loaded with the stream's data.
  • LoadInspections failure: verifies the BLoC emits loading then error with the failure message.
  • Stream propagation: proves that when the database stream emits multiple values, the UI receives each update as a new loaded state.
  • Delete calls repository: uses verify to confirm the correct ID was passed to the use case.
  • Delete error: confirms that a failed delete surfaces the error to the UI.
  • Run the tests to confirm they pass:
flutter test test/features/inspection/presentation/bloc/inspection_list_bloc_test.dart
  • You should see all tests passing with green checkmarks in your terminal output.

Tests failing with type errors?

If you see errors about NoParams not matching, you may need to register a fallback value with mocktail. Add registerFallbackValue(NoParams()) inside a setUpAll block at the top of main(). Also check that your CacheFailure and ServerFailure classes accept a message string in their constructor.

help me fix my InspectionListBloc test failures

✔️ Awesome, I've got everything!

Great. Make sure 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:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';

import 'package:field_inspector/core/error/failures.dart';
import 'package:field_inspector/features/inspection/domain/entities/inspection_entity.dart';
import 'package:field_inspector/features/inspection/domain/usecases/delete_inspection.dart'
    as delete_uc;
import 'package:field_inspector/features/inspection/domain/usecases/get_inspections.dart';
import 'package:field_inspector/features/inspection/presentation/bloc/inspection_list_bloc.dart';

// Mock use cases with mocktail
class MockGetInspections extends Mock implements GetInspections {}

class MockDeleteInspection extends Mock
    implements delete_uc.DeleteInspection {}

void main() {
  late InspectionListBloc bloc;
  late MockGetInspections mockGetInspections;
  late MockDeleteInspection mockDeleteInspection;

  setUp(() {
    mockGetInspections = MockGetInspections();
    mockDeleteInspection = MockDeleteInspection();
    bloc = InspectionListBloc(mockGetInspections, mockDeleteInspection);
  });

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

  // Test data
  final testInspections = [
    InspectionEntity(
      id: '1',
      userId: 'user-1',
      title: 'Bridge Check',
      category: 'Structural',
      status: 'draft',
      createdAt: DateTime(2025, 1, 1),
      updatedAt: DateTime(2025, 1, 1),
    ),
  ];

  group('LoadInspections', () {
    blocTest<InspectionListBloc, InspectionListState>(
      'emits [loading, loaded] when stream returns data',
      build: () {
        when(() => mockGetInspections(any())).thenAnswer(
          (_) async => Right(Stream.value(testInspections)),
        );
        return bloc;
      },
      act: (bloc) =>
          bloc.add(const InspectionListEvent.loadInspections()),
      expect: () => [
        const InspectionListState.loading(),
        InspectionListState.loaded(testInspections),
      ],
    );

    blocTest<InspectionListBloc, InspectionListState>(
      'emits [loading, error] when use case returns failure',
      build: () {
        when(() => mockGetInspections(any())).thenAnswer(
          (_) async => const Left(CacheFailure('Database error')),
        );
        return bloc;
      },
      act: (bloc) =>
          bloc.add(const InspectionListEvent.loadInspections()),
      expect: () => [
        const InspectionListState.loading(),
        const InspectionListState.error('Database error'),
      ],
    );

    blocTest<InspectionListBloc, InspectionListState>(
      'stream updates propagate as new loaded states',
      build: () {
        // Stream emits twice: initial list, then updated list
        final updatedList = [
          ...testInspections,
          InspectionEntity(
            id: '2',
            userId: 'user-1',
            title: 'Roof Inspection',
            category: 'General',
            status: 'draft',
            createdAt: DateTime(2025, 1, 2),
            updatedAt: DateTime(2025, 1, 2),
          ),
        ];
        when(() => mockGetInspections(any())).thenAnswer(
          (_) async => Right(
            Stream.fromIterable([testInspections, updatedList]),
          ),
        );
        return bloc;
      },
      act: (bloc) =>
          bloc.add(const InspectionListEvent.loadInspections()),
      expect: () => [
        const InspectionListState.loading(),
        InspectionListState.loaded(testInspections),
        // Second emission from the stream
        isA<InspectionListLoaded>(),
      ],
    );
  });

  group('DeleteInspection', () {
    blocTest<InspectionListBloc, InspectionListState>(
      'calls delete use case with correct id',
      build: () {
        when(() => mockDeleteInspection(any())).thenAnswer(
          (_) async => const Right(unit),
        );
        return bloc;
      },
      act: (bloc) => bloc
          .add(const InspectionListEvent.deleteInspection('1')),
      verify: (_) {
        verify(() => mockDeleteInspection('1')).called(1);
      },
    );

    blocTest<InspectionListBloc, InspectionListState>(
      'emits error state when delete fails',
      build: () {
        when(() => mockDeleteInspection(any())).thenAnswer(
          (_) async =>
              const Left(ServerFailure('Delete failed')),
        );
        return bloc;
      },
      act: (bloc) => bloc
          .add(const InspectionListEvent.deleteInspection('1')),
      expect: () => [
        const InspectionListState.error('Delete failed'),
      ],
    );
  });
}

Your inspection list screen is alive. The BLoC subscribes to the database stream, the UI renders each state transition, and the test suite proves it all works. Next up, you will build the inspection form screen where users create and edit inspections with dynamic checklist items and GPS capture.

Build the Inspection Form Screen

Your inspection list screen displays existing inspections. Now you need a way to create and edit them.

Inspections have structured data: a title, location, category, and a dynamic list of checklist items with severity ratings. In this step, you will build the form screen that captures all of this, including auto-capturing the device's GPS coordinates.

In this step, get ready to:
  • Create the InspectionFormBloc with Freezed events and states for form management.
  • Build a scrollable form with dynamic checklist items and form validation.
  • Integrate GPS capture via Geolocator with permission handling.
Create the InspectionFormBloc

The form screen needs its own BLoC to manage form data, dynamic checklist items, validation errors, and the save lifecycle. Each user action on the form (adding an item, typing a title, tapping save) maps to a distinct event.

  • Create a new file at lib/features/inspection/presentation/bloc/inspection_form_event.dart and add the Freezed event definitions:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_form_event.freezed.dart';

// Events representing user actions on the inspection form
@freezed
class InspectionFormEvent with _$InspectionFormEvent {
  // Triggered when the user taps Save
  const factory InspectionFormEvent.saveInspection() = SaveInspection;

  // Triggered when the user adds a new checklist item
  const factory InspectionFormEvent.addItem() = AddItem;

  // Triggered when the user removes a checklist item by index
  const factory InspectionFormEvent.removeItem(int index) = RemoveItem;

  // Triggered when the user edits a checklist item field
  const factory InspectionFormEvent.updateItem({
    required int index,
    String? description,
    String? severity,
    bool? isCompleted,
    String? notes,
  }) = UpdateItem;

  // Triggered when the user taps Capture Location
  const factory InspectionFormEvent.captureLocation() = CaptureLocation;
}

What does this code do?

Each factory constructor defines one user interaction. SaveInspection fires when the user taps the save button. AddItem appends a blank checklist item to the form.

UpdateItem carries optional fields so you can update any single property of a checklist item without re-sending the entire object. CaptureLocation triggers the GPS permission and position capture flow.

  • Create the Freezed state file at lib/features/inspection/presentation/bloc/inspection_form_state.dart:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'inspection_form_state.freezed.dart';

// Represents a single checklist item in the form
@freezed
class FormItem with _$FormItem {
  const factory FormItem({
    @Default('') String description,
    @Default('low') String severity,
    @Default(false) bool isCompleted,
    @Default('') String notes,
  }) = _FormItem;
}

// Tracks the entire form state including validation and save status
@freezed
class InspectionFormState with _$InspectionFormState {
  const factory InspectionFormState({
    @Default('') String title,
    @Default('') String locationName,
    @Default('General') String category,
    @Default([]) List<FormItem> items,
    double? latitude,
    double? longitude,
    @Default({}) Map<String, String> validationErrors,
    @Default(SaveStatus.initial) SaveStatus saveStatus,
  }) = _InspectionFormState;
}

// Enum for tracking the save lifecycle
enum SaveStatus { initial, saving, success, failure }

What does this code do?

FormItem models a single checklist entry with sensible defaults (empty description, low severity, not completed). The InspectionFormState holds all form fields, a list of dynamic items, optional GPS coordinates, a validation errors map, and a SaveStatus enum tracking the save lifecycle.

The validationErrors map uses field names as keys and error messages as values. This lets the UI display errors next to specific fields.

  • Create the BLoC file at lib/features/inspection/presentation/bloc/inspection_form_bloc.dart:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:injectable/injectable.dart';

import 'inspection_form_event.dart';
import 'inspection_form_state.dart';
import '../../domain/usecases/create_inspection.dart';

// BLoC managing the inspection form lifecycle
@injectable
class InspectionFormBloc
    extends Bloc<InspectionFormEvent, InspectionFormState> {
  final CreateInspection _createInspection;

  InspectionFormBloc(this._createInspection)
      : super(const InspectionFormState()) {
    on<SaveInspection>(_onSaveInspection);
    on<AddItem>(_onAddItem);
    on<RemoveItem>(_onRemoveItem);
    on<UpdateItem>(_onUpdateItem);
    on<CaptureLocation>(_onCaptureLocation);
  }

  // Validates form and calls the create use case
  Future<void> _onSaveInspection(
    SaveInspection event,
    Emitter<InspectionFormState> emit,
  ) async {
    final errors = _validate();
    if (errors.isNotEmpty) {
      emit(state.copyWith(
        validationErrors: errors,
        saveStatus: SaveStatus.initial,
      ));
      return;
    }

    emit(state.copyWith(
      saveStatus: SaveStatus.saving,
      validationErrors: {},
    ));

    final result = await _createInspection(CreateInspectionParams(
      title: state.title,
      locationName: state.locationName,
      category: state.category,
      latitude: state.latitude,
      longitude: state.longitude,
      items: state.items,
    ));

    result.fold(
      (failure) => emit(state.copyWith(saveStatus: SaveStatus.failure)),
      (_) => emit(state.copyWith(saveStatus: SaveStatus.success)),
    );
  }

  // Appends a blank checklist item
  void _onAddItem(AddItem event, Emitter<InspectionFormState> emit) {
    final updatedItems = List<FormItem>.from(state.items)
      ..add(const FormItem());
    emit(state.copyWith(items: updatedItems));
  }

  // Removes item at the given index
  void _onRemoveItem(RemoveItem event, Emitter<InspectionFormState> emit) {
    final updatedItems = List<FormItem>.from(state.items)
      ..removeAt(event.index);
    emit(state.copyWith(items: updatedItems));
  }

  // Updates a specific field on a checklist item
  void _onUpdateItem(UpdateItem event, Emitter<InspectionFormState> emit) {
    final updatedItems = List<FormItem>.from(state.items);
    final current = updatedItems[event.index];
    updatedItems[event.index] = current.copyWith(
      description: event.description ?? current.description,
      severity: event.severity ?? current.severity,
      isCompleted: event.isCompleted ?? current.isCompleted,
      notes: event.notes ?? current.notes,
    );
    emit(state.copyWith(items: updatedItems));
  }

  // Handles permission checks and GPS position capture
  Future<void> _onCaptureLocation(
    CaptureLocation event,
    Emitter<InspectionFormState> emit,
  ) async {
    // Check if location services are enabled
    final serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) {
      emit(state.copyWith(
        validationErrors: {'location': 'Location services are disabled'},
      ));
      return;
    }

    // Check and request permission
    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) {
        emit(state.copyWith(
          validationErrors: {'location': 'Location permission denied'},
        ));
        return;
      }
    }

    if (permission == LocationPermission.deniedForever) {
      emit(state.copyWith(
        validationErrors: {
          'location': 'Location permission permanently denied'
        },
      ));
      return;
    }

    // Capture the current position
    final position = await Geolocator.getCurrentPosition(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
      ),
    );

    emit(state.copyWith(
      latitude: position.latitude,
      longitude: position.longitude,
      validationErrors: {},
    ));
  }

  // Validates required fields
  Map<String, String> _validate() {
    final errors = <String, String>{};
    if (state.title.trim().isEmpty) {
      errors['title'] = 'Title is required';
    }
    if (state.locationName.trim().isEmpty) {
      errors['locationName'] = 'Location name is required';
    }
    return errors;
  }
}

What does this code do?

The BLoC handles five events. _onSaveInspection validates the form, then calls the CreateInspection use case. If validation fails, it populates the errors map so the UI can display inline errors.

_onCaptureLocation follows the Geolocator permission flow: check services, check permission, request if denied, then call getCurrentPosition. The position's latitude and longitude are stored in state for the form to display.

  • Run code generation to produce the Freezed files:
flutter pub run build_runner build --delete-conflicting-outputs
  • Confirm the build completes without errors. You should see generated .freezed.dart files appear alongside your event and state files.

Build runner failing?

Check that your part directives match the filenames exactly. The part statement in inspection_form_event.dart must be part 'inspection_form_event.freezed.dart'; with no typos.

Also verify that freezed_annotation is in your regular dependencies and freezed is in dev dependencies. help me fix my build_runner errors for the InspectionFormBloc Freezed files

Build the scrollable inspection form

The form screen presents all inspection fields in a scrollable layout. The dynamic checklist section lets inspectors add multiple items, each with its own severity rating.

  • Create lib/features/inspection/presentation/pages/inspection_form_page.dart with the form UI:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/inspection_form_bloc.dart';
import '../bloc/inspection_form_event.dart';
import '../bloc/inspection_form_state.dart';

// Main form page for creating/editing inspections
class InspectionFormPage extends StatefulWidget {
  const InspectionFormPage({super.key});

  @override
  State<InspectionFormPage> createState() => _InspectionFormPageState();
}

class _InspectionFormPageState extends State<InspectionFormPage> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _locationController = TextEditingController();

  // Available inspection categories
  static const _categories = [
    'Structural',
    'Electrical',
    'Plumbing',
    'Fire Safety',
    'General',
  ];

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

  @override
  Widget build(BuildContext context) {
    return BlocConsumer<InspectionFormBloc, InspectionFormState>(
      listener: (context, state) {
        // Navigate back on successful save
        if (state.saveStatus == SaveStatus.success) {
          Navigator.of(context).pop();
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Inspection saved')),
          );
        }
        if (state.saveStatus == SaveStatus.failure) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Failed to save inspection')),
          );
        }
      },
      builder: (context, state) {
        return Scaffold(
          appBar: AppBar(title: const Text('New Inspection')),
          body: Form(
            key: _formKey,
            child: ListView(
              padding: const EdgeInsets.all(16),
              children: [
                // Title field
                TextFormField(
                  controller: _titleController,
                  decoration: InputDecoration(
                    labelText: 'Title',
                    errorText: state.validationErrors['title'],
                  ),
                  onChanged: (value) {
                    // Update bloc state with title
                  },
                ),
                const SizedBox(height: 16),

                // Location name field
                TextFormField(
                  controller: _locationController,
                  decoration: InputDecoration(
                    labelText: 'Location Name',
                    errorText: state.validationErrors['locationName'],
                  ),
                  onChanged: (value) {
                    // Update bloc state with location name
                  },
                ),
                const SizedBox(height: 16),

                // Category dropdown
                DropdownButtonFormField<String>(
                  value: state.category,
                  decoration: const InputDecoration(
                    labelText: 'Category',
                  ),
                  items: _categories
                      .map((c) => DropdownMenuItem(
                            value: c,
                            child: Text(c),
                          ))
                      .toList(),
                  onChanged: (value) {
                    // Update bloc state with selected category
                  },
                ),
                const SizedBox(height: 24),

                // GPS capture section
                _buildLocationSection(context, state),
                const SizedBox(height: 24),

                // Dynamic checklist items section
                _buildChecklistSection(context, state),
                const SizedBox(height: 24),

                // Save button
                FilledButton(
                  onPressed: state.saveStatus == SaveStatus.saving
                      ? null
                      : () => context
                          .read<InspectionFormBloc>()
                          .add(const InspectionFormEvent.saveInspection()),
                  child: state.saveStatus == SaveStatus.saving
                      ? const CircularProgressIndicator()
                      : const Text('Save Inspection'),
                ),
              ],
            ),
          ),
        );
      },
    );
  }

  // Builds the GPS coordinates display and capture button
  Widget _buildLocationSection(
      BuildContext context, InspectionFormState state) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('GPS Location',
                style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            if (state.latitude != null && state.longitude != null) ...[
              // Read-only lat/long display
              TextFormField(
                readOnly: true,
                initialValue: state.latitude!.toStringAsFixed(6),
                decoration: const InputDecoration(labelText: 'Latitude'),
              ),
              const SizedBox(height: 8),
              TextFormField(
                readOnly: true,
                initialValue: state.longitude!.toStringAsFixed(6),
                decoration: const InputDecoration(labelText: 'Longitude'),
              ),
            ],
            if (state.validationErrors.containsKey('location'))
              Padding(
                padding: const EdgeInsets.only(top: 8),
                child: Text(
                  state.validationErrors['location']!,
                  style: TextStyle(
                      color: Theme.of(context).colorScheme.error),
                ),
              ),
            const SizedBox(height: 12),
            OutlinedButton.icon(
              onPressed: () => context
                  .read<InspectionFormBloc>()
                  .add(const InspectionFormEvent.captureLocation()),
              icon: const Icon(Icons.my_location),
              label: const Text('Capture Location'),
            ),
          ],
        ),
      ),
    );
  }

  // Builds the dynamic checklist items list with add button
  Widget _buildChecklistSection(
      BuildContext context, InspectionFormState state) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text('Checklist Items',
                style: Theme.of(context).textTheme.titleMedium),
            TextButton.icon(
              onPressed: () => context
                  .read<InspectionFormBloc>()
                  .add(const InspectionFormEvent.addItem()),
              icon: const Icon(Icons.add),
              label: const Text('Add Item'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        // Render each checklist item card
        ...List.generate(state.items.length, (index) {
          final item = state.items[index];
          return _buildItemCard(context, item, index);
        }),
      ],
    );
  }

  // Builds an individual checklist item card
  Widget _buildItemCard(BuildContext context, FormItem item, int index) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          children: [
            // Description field
            TextFormField(
              initialValue: item.description,
              decoration: const InputDecoration(
                labelText: 'Description',
                hintText: 'What needs inspection?',
              ),
              onChanged: (value) => context
                  .read<InspectionFormBloc>()
                  .add(InspectionFormEvent.updateItem(
                    index: index,
                    description: value,
                  )),
            ),
            const SizedBox(height: 8),

            // Severity dropdown and completion checkbox row
            Row(
              children: [
                Expanded(
                  child: DropdownButtonFormField<String>(
                    value: item.severity,
                    decoration: const InputDecoration(
                      labelText: 'Severity',
                    ),
                    items: ['low', 'medium', 'high', 'critical']
                        .map((s) => DropdownMenuItem(
                              value: s,
                              child: Text(s[0].toUpperCase() +
                                  s.substring(1)),
                            ))
                        .toList(),
                    onChanged: (value) => context
                        .read<InspectionFormBloc>()
                        .add(InspectionFormEvent.updateItem(
                          index: index,
                          severity: value,
                        )),
                  ),
                ),
                // Completion checkbox
                Checkbox(
                  value: item.isCompleted,
                  onChanged: (value) => context
                      .read<InspectionFormBloc>()
                      .add(InspectionFormEvent.updateItem(
                        index: index,
                        isCompleted: value,
                      )),
                ),
                const Text('Done'),
              ],
            ),
            const SizedBox(height: 8),

            // Notes field
            TextFormField(
              initialValue: item.notes,
              decoration: const InputDecoration(
                labelText: 'Notes',
                hintText: 'Additional observations',
              ),
              maxLines: 2,
              onChanged: (value) => context
                  .read<InspectionFormBloc>()
                  .add(InspectionFormEvent.updateItem(
                    index: index,
                    notes: value,
                  )),
            ),
            const SizedBox(height: 4),

            // Remove item button
            Align(
              alignment: Alignment.centerRight,
              child: TextButton(
                onPressed: () => context
                    .read<InspectionFormBloc>()
                    .add(InspectionFormEvent.removeItem(index)),
                child: const Text('Remove',
                    style: TextStyle(color: Colors.red)),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

What does this code do?

The form uses a BlocConsumer to both listen for save success/failure (showing snackbars, popping navigation) and rebuild the UI on state changes. The ListView makes the form scrollable so it works on smaller screens.

The checklist section is fully dynamic. Each item renders a Card with description, severity dropdown (low/medium/high/critical), a completion checkbox, and notes. The UpdateItem event fires on every field change so state stays in sync with the BLoC.

The location section shows read-only text fields for latitude and longitude after capture. The Capture Location button dispatches the CaptureLocation event, and any permission errors display inline below the button.

  • Save inspection_form_page.dart.
  • Run your app on the emulator. Tap the FAB on the inspection list. You should see the form with title, location name, category dropdown, an empty checklist section with an Add Item button, the GPS capture card, and a Save Inspection button at the bottom.

Form not appearing when you tap the FAB?

Make sure your FAB's onPressed in inspection_list_page.dart navigates to InspectionFormPage and wraps it in a BlocProvider that creates the InspectionFormBloc from GetIt.

Also confirm you registered InspectionFormBloc in DI by re-running build_runner after adding the @injectable annotation. help me debug why InspectionFormPage is not loading when I tap the FAB

Add GPS capture with Geolocator permissions

The Geolocator plugin needs a manifest permission entry to function on Android. Without it, the runtime permission request will throw a PermissionDefinitionsNotFoundException.

  • Open android/app/src/main/AndroidManifest.xml.
  • Find the opening <manifest> tag at the top of the file.
  • Add the location permission inside the <manifest> element, before the <application> tag:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Why ACCESS_FINE_LOCATION?

Android requires explicit permission declarations in the manifest. ACCESS_FINE_LOCATION grants GPS-level precision (within a few meters). The Geolocator plugin handles the runtime permission dialog, but the manifest entry must exist or Android blocks the request entirely.

Your project's compile SDK must be 35 for this to work correctly with the Geolocator plugin.

  • Save AndroidManifest.xml.
  • Hot restart your app on the emulator (stop and re-run, since manifest changes require a full rebuild).
  • Navigate to the form screen and tap Capture Location. You should see a permission dialog appear. Grant the permission.
  • After granting, the latitude and longitude fields should populate with coordinates. On the emulator, these will be the default emulated location (typically near the Googleplex in Mountain View).

Set a custom emulator location

In Android Studio's emulator, click the three-dot menu, go to Location, and set custom coordinates before tapping Capture Location in your app. This lets you test with realistic field inspection coordinates.

Permission dialog not appearing?

Verify the ACCESS_FINE_LOCATION permission line is inside the <manifest> element but outside the <application> element. Placing it inside <application> is a common mistake that silently breaks permission requests.

If you changed the manifest while the app was running, a hot reload is not sufficient. You must stop and re-run the app completely. help me fix Geolocator permission issues on Android emulator

✔️ Awesome, I've got everything!

Great. Double check you have saved all three BLoC files and the manifest. Your form should display, accept dynamic checklist items, and capture GPS coordinates on tap.

ⓧ I'd like to double check the full code

Here is the full inspection_form_bloc.dart for reference:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:injectable/injectable.dart';

import 'inspection_form_event.dart';
import 'inspection_form_state.dart';
import '../../domain/usecases/create_inspection.dart';

// BLoC managing the inspection form lifecycle
@injectable
class InspectionFormBloc
    extends Bloc<InspectionFormEvent, InspectionFormState> {
  final CreateInspection _createInspection;

  InspectionFormBloc(this._createInspection)
      : super(const InspectionFormState()) {
    on<SaveInspection>(_onSaveInspection);
    on<AddItem>(_onAddItem);
    on<RemoveItem>(_onRemoveItem);
    on<UpdateItem>(_onUpdateItem);
    on<CaptureLocation>(_onCaptureLocation);
  }

  // Validates form and calls the create use case
  Future<void> _onSaveInspection(
    SaveInspection event,
    Emitter<InspectionFormState> emit,
  ) async {
    final errors = _validate();
    if (errors.isNotEmpty) {
      emit(state.copyWith(
        validationErrors: errors,
        saveStatus: SaveStatus.initial,
      ));
      return;
    }

    emit(state.copyWith(
      saveStatus: SaveStatus.saving,
      validationErrors: {},
    ));

    final result = await _createInspection(CreateInspectionParams(
      title: state.title,
      locationName: state.locationName,
      category: state.category,
      latitude: state.latitude,
      longitude: state.longitude,
      items: state.items,
    ));

    result.fold(
      (failure) => emit(state.copyWith(saveStatus: SaveStatus.failure)),
      (_) => emit(state.copyWith(saveStatus: SaveStatus.success)),
    );
  }

  // Appends a blank checklist item
  void _onAddItem(AddItem event, Emitter<InspectionFormState> emit) {
    final updatedItems = List<FormItem>.from(state.items)
      ..add(const FormItem());
    emit(state.copyWith(items: updatedItems));
  }

  // Removes item at the given index
  void _onRemoveItem(RemoveItem event, Emitter<InspectionFormState> emit) {
    final updatedItems = List<FormItem>.from(state.items)
      ..removeAt(event.index);
    emit(state.copyWith(items: updatedItems));
  }

  // Updates a specific field on a checklist item
  void _onUpdateItem(UpdateItem event, Emitter<InspectionFormState> emit) {
    final updatedItems = List<FormItem>.from(state.items);
    final current = updatedItems[event.index];
    updatedItems[event.index] = current.copyWith(
      description: event.description ?? current.description,
      severity: event.severity ?? current.severity,
      isCompleted: event.isCompleted ?? current.isCompleted,
      notes: event.notes ?? current.notes,
    );
    emit(state.copyWith(items: updatedItems));
  }

  // Handles permission checks and GPS position capture
  Future<void> _onCaptureLocation(
    CaptureLocation event,
    Emitter<InspectionFormState> emit,
  ) async {
    // Check if location services are enabled
    final serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) {
      emit(state.copyWith(
        validationErrors: {'location': 'Location services are disabled'},
      ));
      return;
    }

    // Check and request permission
    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) {
        emit(state.copyWith(
          validationErrors: {'location': 'Location permission denied'},
        ));
        return;
      }
    }

    if (permission == LocationPermission.deniedForever) {
      emit(state.copyWith(
        validationErrors: {
          'location': 'Location permission permanently denied'
        },
      ));
      return;
    }

    // Capture the current position
    final position = await Geolocator.getCurrentPosition(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
      ),
    );

    emit(state.copyWith(
      latitude: position.latitude,
      longitude: position.longitude,
      validationErrors: {},
    ));
  }

  // Validates required fields
  Map<String, String> _validate() {
    final errors = <String, String>{};
    if (state.title.trim().isEmpty) {
      errors['title'] = 'Title is required';
    }
    if (state.locationName.trim().isEmpty) {
      errors['locationName'] = 'Location name is required';
    }
    return errors;
  }
}

And the manifest permission line in android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Your inspection form is fully wired. Inspectors can now create structured inspections with categorized checklists and GPS-tagged locations. Next up, you will integrate the camera so inspectors can attach photographic evidence to each checklist item.

Integrate Camera and Photo Attachment

Your inspection form captures structured data, but real inspections need photographic evidence. A photo of a cracked beam or a corroded pipe says more than any text description.

In this step, you will build a PhotoService that wraps the image_picker plugin, wire photo thumbnails into each checklist item on the inspection form, and create a full-screen photo viewer with swipe navigation and delete.

In this step, get ready to:
  • Create a PhotoService that captures from camera or gallery and saves images permanently.
  • Add photo attachment UI to each checklist item on the inspection form.
  • Build a full-screen photo viewer with Hero animations and swipe navigation.
Create the PhotoService

The image_picker plugin saves captured photos to the app's temporary cache. If the cache is cleared, those photos disappear. Your PhotoService will copy each image to the app's permanent documents directory with a UUID filename, ensuring photos survive between sessions.

  • Create the file lib/features/inspection/data/services/photo_service.dart by adding the following code:
import 'dart:io';

import 'package:image_picker/image_picker.dart';
import 'package:injectable/injectable.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';

/// Service that wraps image_picker to capture or pick photos
/// and persist them in the app documents directory.
@LazySingleton()
class PhotoService {
  final ImagePicker _picker = ImagePicker();
  final Uuid _uuid = const Uuid();

  /// Captures a photo from the device camera.
  /// Returns the permanent file path, or null if cancelled.
  Future<String?> captureFromCamera() async {
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.camera,
      imageQuality: 85,
    );
    if (photo == null) return null;
    return _saveToDocuments(photo);
  }

  /// Picks a photo from the device gallery.
  /// Returns the permanent file path, or null if cancelled.
  Future<String?> pickFromGallery() async {
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.gallery,
      imageQuality: 85,
    );
    if (photo == null) return null;
    return _saveToDocuments(photo);
  }

  /// Copies the picked file from the temp cache to app documents
  /// with a unique UUID filename so it persists permanently.
  Future<String> _saveToDocuments(XFile photo) async {
    final Directory appDir = await getApplicationDocumentsDirectory();
    final String extension = photo.path.split('.').last;
    final String fileName = '${_uuid.v4()}.$extension';
    final String permanentPath = '${appDir.path}/$fileName';

    // Copy from temp cache to permanent storage
    final File tempFile = File(photo.path);
    await tempFile.copy(permanentPath);

    return permanentPath;
  }
}

What does this code do?

The @LazySingleton() annotation tells Injectable to register this class as a single shared instance in GetIt.

Both captureFromCamera() and pickFromGallery() call pickImage() with different ImageSource values. The plugin returns an XFile pointing to a temporary cache location. The private _saveToDocuments() method copies that file to the app's permanent documents directory using a UUID filename so photos never collide.

  • Save photo_service.dart.
  • Regenerate the DI graph by running:
flutter pub run build_runner build --delete-conflicting-outputs
  • You should see the build complete with no errors and injection.config.dart updated to include PhotoService.

Build runner showing errors?

Make sure you have the image_picker, path_provider, and uuid packages in your pubspec.yaml dependencies. These were added in Step 3, but double check they are present.

help me fix build_runner errors for PhotoService

Add photo UI to checklist items

Each inspection checklist item needs a way to attach evidence photos. You will add a horizontal thumbnail row below each item and an "Add Photo" button that shows a bottom sheet with Camera and Gallery options.

  • In lib/features/inspection/presentation/pages/inspection_form_page.dart, find the section where you build each checklist item widget.
  • Below the notes field for each item, add the following photo attachment widget:
/// Photo attachment section for a checklist item.
/// Shows existing thumbnails and an 'Add Photo' button.
Widget _buildPhotoSection(int itemIndex, List<String> photoPaths) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      // Horizontal scrollable row of thumbnails
      if (photoPaths.isNotEmpty)
        SizedBox(
          height: 80,
          child: ListView.builder(
            scrollDirection: Axis.horizontal,
            itemCount: photoPaths.length,
            itemBuilder: (context, photoIndex) {
              final photoId = '${itemIndex}_$photoIndex';
              return Padding(
                padding: const EdgeInsets.only(right: 8.0),
                child: GestureDetector(
                  onTap: () {
                    // Navigate to full-screen viewer
                    Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (_) => PhotoViewerPage(
                          photos: photoPaths,
                          initialIndex: photoIndex,
                          heroTagPrefix: '$itemIndex',
                        ),
                      ),
                    );
                  },
                  child: Hero(
                    tag: 'photo_$photoId',
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(8),
                      child: Image.file(
                        File(photoPaths[photoIndex]),
                        width: 80,
                        height: 80,
                        fit: BoxFit.cover,
                      ),
                    ),
                  ),
                ),
              );
            },
          ),
        ),
      const SizedBox(height: 8),
      // Add Photo button that triggers bottom sheet
      TextButton.icon(
        onPressed: () => _showPhotoSourceSheet(itemIndex),
        icon: const Icon(Icons.add_a_photo),
        label: const Text('Add Photo'),
      ),
    ],
  );
}

What does this code do?

The _buildPhotoSection method renders a horizontal scrollable list of photo thumbnails. Each thumbnail is wrapped in a Hero widget so Flutter can animate a smooth transition when the learner taps to view the photo full-screen.

The GestureDetector on each thumbnail navigates to the PhotoViewerPage you will build next. The "Add Photo" button calls _showPhotoSourceSheet which presents the Camera/Gallery bottom sheet.

  • Now add the bottom sheet method that lets the user choose Camera or Gallery:
/// Shows a bottom sheet with Camera and Gallery options.
void _showPhotoSourceSheet(int itemIndex) {
  showModalBottomSheet(
    context: context,
    builder: (context) => SafeArea(
      child: Wrap(
        children: [
          ListTile(
            leading: const Icon(Icons.camera_alt),
            title: const Text('Camera'),
            onTap: () {
              Navigator.pop(context);
              _capturePhoto(itemIndex, fromCamera: true);
            },
          ),
          ListTile(
            leading: const Icon(Icons.photo_library),
            title: const Text('Gallery'),
            onTap: () {
              Navigator.pop(context);
              _capturePhoto(itemIndex, fromCamera: false);
            },
          ),
        ],
      ),
    ),
  );
}

/// Captures or picks a photo and adds it to the item's photo list.
Future<void> _capturePhoto(int itemIndex, {required bool fromCamera}) async {
  final photoService = GetIt.instance<PhotoService>();
  final String? path = fromCamera
      ? await photoService.captureFromCamera()
      : await photoService.pickFromGallery();

  if (path != null) {
    // Add photo path to this item's list and persist via DAO
    context.read<InspectionFormBloc>().add(
      InspectionFormEvent.addPhoto(
        itemIndex: itemIndex,
        photoPath: path,
      ),
    );
  }
}

What does this code do?

The _showPhotoSourceSheet method uses showModalBottomSheet to present two options: Camera and Gallery. Tapping either one dismisses the sheet and calls _capturePhoto.

The _capturePhoto method retrieves PhotoService from the DI container, calls the appropriate method, and dispatches an event to the InspectionFormBloc with the new photo path. The BLoC stores the path and the InspectionPhotoDao persists it in the local database.

  • Save inspection_form_page.dart.
  • Run the app on your emulator and navigate to an inspection form.
  • You should see an "Add Photo" button below each checklist item. Tapping it shows a bottom sheet with Camera and Gallery options.

Bottom sheet not appearing?

Check that _showPhotoSourceSheet is defined inside the State class of your page, where it has access to context. Also verify that the "Add Photo" button's onPressed callback calls the method with the correct itemIndex.

help me debug my bottom sheet not showing for photo selection

Build the full-screen photo viewer

Inspectors need to zoom into photos and swipe between them. A dedicated viewer page with PageView gives smooth swipe navigation, while Hero animations create a natural transition from thumbnail to full-screen.

  • Create the file lib/features/inspection/presentation/pages/photo_viewer_page.dart with the following code:
import 'dart:io';

import 'package:flutter/material.dart';

/// Full-screen photo viewer with swipe navigation and delete.
class PhotoViewerPage extends StatefulWidget {
  final List<String> photos;
  final int initialIndex;
  final String heroTagPrefix;
  final void Function(int index)? onDelete;

  const PhotoViewerPage({
    super.key,
    required this.photos,
    required this.initialIndex,
    required this.heroTagPrefix,
    this.onDelete,
  });

  @override
  State<PhotoViewerPage> createState() => _PhotoViewerPageState();
}

class _PhotoViewerPageState extends State<PhotoViewerPage> {
  late PageController _pageController;
  late int _currentIndex;

  @override
  void initState() {
    super.initState();
    _currentIndex = widget.initialIndex;
    _pageController = PageController(initialPage: _currentIndex);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.black,
        foregroundColor: Colors.white,
        title: Text(
          '${_currentIndex + 1} / ${widget.photos.length}',
        ),
        actions: [
          // Delete button removes the current photo
          IconButton(
            icon: const Icon(Icons.delete, color: Colors.red),
            onPressed: () {
              widget.onDelete?.call(_currentIndex);
              Navigator.pop(context);
            },
          ),
        ],
      ),
      body: PageView.builder(
        controller: _pageController,
        itemCount: widget.photos.length,
        onPageChanged: (index) {
          setState(() => _currentIndex = index);
        },
        itemBuilder: (context, index) {
          final photoId = '${widget.heroTagPrefix}_$index';
          return Hero(
            tag: 'photo_$photoId',
            child: InteractiveViewer(
              // Allows pinch-to-zoom on the photo
              minScale: 1.0,
              maxScale: 4.0,
              child: Center(
                child: Image.file(
                  File(widget.photos[index]),
                  fit: BoxFit.contain,
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

What does this code do?

The PhotoViewerPage uses a PageView.builder so the user can swipe left and right between all photos attached to that checklist item. The page counter in the app bar updates as you swipe.

Each page wraps the image in InteractiveViewer for pinch-to-zoom. The Hero tag matches the tag used in the thumbnail grid, creating a seamless zoom-in animation when opening and a zoom-out when closing.

The delete button calls the onDelete callback with the current index, letting the parent page handle removing the photo from state and the database.

  • Save photo_viewer_page.dart.
  • Run the app, add a photo to a checklist item (use the Gallery option on the emulator since there is no physical camera).
  • Tap the thumbnail. You should see a smooth Hero animation into a full-screen black viewer with the photo centered and a page counter in the app bar.

Hero animation not working?

The Hero tag must be identical on both the thumbnail and the viewer. Check that heroTagPrefix passed to PhotoViewerPage matches the prefix used in _buildPhotoSection. Both should produce a tag like photo_0_0 for the first photo of the first item.

help me debug Hero animation between thumbnail and full-screen photo viewer

✔️ Awesome, I've got everything!

Great. Double check you have saved both photo_service.dart and photo_viewer_page.dart, and that your inspection_form_page.dart includes the photo section and bottom sheet methods.

ⓧ I'd like to double check the full code

Here is the complete photo_service.dart:

import 'dart:io';

import 'package:image_picker/image_picker.dart';
import 'package:injectable/injectable.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';

/// Service that wraps image_picker to capture or pick photos
/// and persist them in the app documents directory.
@LazySingleton()
class PhotoService {
  final ImagePicker _picker = ImagePicker();
  final Uuid _uuid = const Uuid();

  /// Captures a photo from the device camera.
  /// Returns the permanent file path, or null if cancelled.
  Future<String?> captureFromCamera() async {
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.camera,
      imageQuality: 85,
    );
    if (photo == null) return null;
    return _saveToDocuments(photo);
  }

  /// Picks a photo from the device gallery.
  /// Returns the permanent file path, or null if cancelled.
  Future<String?> pickFromGallery() async {
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.gallery,
      imageQuality: 85,
    );
    if (photo == null) return null;
    return _saveToDocuments(photo);
  }

  /// Copies the picked file from the temp cache to app documents
  /// with a unique UUID filename so it persists permanently.
  Future<String> _saveToDocuments(XFile photo) async {
    final Directory appDir = await getApplicationDocumentsDirectory();
    final String extension = photo.path.split('.').last;
    final String fileName = '${_uuid.v4()}.$extension';
    final String permanentPath = '${appDir.path}/$fileName';

    // Copy from temp cache to permanent storage
    final File tempFile = File(photo.path);
    await tempFile.copy(permanentPath);

    return permanentPath;
  }
}

Here is the complete photo_viewer_page.dart:

import 'dart:io';

import 'package:flutter/material.dart';

/// Full-screen photo viewer with swipe navigation and delete.
class PhotoViewerPage extends StatefulWidget {
  final List<String> photos;
  final int initialIndex;
  final String heroTagPrefix;
  final void Function(int index)? onDelete;

  const PhotoViewerPage({
    super.key,
    required this.photos,
    required this.initialIndex,
    required this.heroTagPrefix,
    this.onDelete,
  });

  @override
  State<PhotoViewerPage> createState() => _PhotoViewerPageState();
}

class _PhotoViewerPageState extends State<PhotoViewerPage> {
  late PageController _pageController;
  late int _currentIndex;

  @override
  void initState() {
    super.initState();
    _currentIndex = widget.initialIndex;
    _pageController = PageController(initialPage: _currentIndex);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.black,
        foregroundColor: Colors.white,
        title: Text(
          '${_currentIndex + 1} / ${widget.photos.length}',
        ),
        actions: [
          // Delete button removes the current photo
          IconButton(
            icon: const Icon(Icons.delete, color: Colors.red),
            onPressed: () {
              widget.onDelete?.call(_currentIndex);
              Navigator.pop(context);
            },
          ),
        ],
      ),
      body: PageView.builder(
        controller: _pageController,
        itemCount: widget.photos.length,
        onPageChanged: (index) {
          setState(() => _currentIndex = index);
        },
        itemBuilder: (context, index) {
          final photoId = '${widget.heroTagPrefix}_$index';
          return Hero(
            tag: 'photo_$photoId',
            child: InteractiveViewer(
              // Allows pinch-to-zoom on the photo
              minScale: 1.0,
              maxScale: 4.0,
              child: Center(
                child: Image.file(
                  File(widget.photos[index]),
                  fit: BoxFit.contain,
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

Your inspection form now supports full photo evidence capture. Next up, you will integrate the QR/barcode scanner so inspectors can tag physical assets to their inspection records.

Integrate QR and Barcode Scanner

Your inspection form can now capture photos, but field inspectors also need to tag physical assets. Equipment, pipes, and structural elements often have QR codes or barcodes attached for identification.

In this step, you will build a barcode scanner feature that reads QR codes and barcodes from the device camera. The scanned value automatically populates the inspection's asset ID field, linking the digital inspection record to the physical asset.

In this step, get ready to:
  • Create a ScannerBloc with Freezed events and states to manage scanner lifecycle.
  • Build a scanner page with live camera preview, scan window overlay, and torch toggle.
  • Wire the scanner into the inspection form so scanned values auto-populate the asset ID.
Create the ScannerBloc with Freezed events and states

The scanner feature follows the same Clean Architecture pattern as the rest of your app. The BLoC manages the MobileScannerController lifecycle and translates raw barcode detections into typed state changes.

  • Create the scanner feature folder structure by running this command from your project root:
mkdir -p lib/features/scanner/presentation/bloc lib/features/scanner/presentation/pages
  • Create the Freezed event and state file at lib/features/scanner/presentation/bloc/scanner_event.dart with the following code:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'scanner_event.freezed.dart';

/// Events that drive the scanner lifecycle
@freezed
class ScannerEvent with _$ScannerEvent {
  /// User opens the scanner screen
  const factory ScannerEvent.startScan() = StartScan;

  /// A barcode or QR code was detected
  const factory ScannerEvent.scanDetected(String value) = ScanDetected;

  /// User leaves the scanner screen
  const factory ScannerEvent.stopScan() = StopScan;
}

What does this code do?

Each event maps to a user action or system detection. StartScan fires when the scanner page opens. ScanDetected carries the raw barcode string value. StopScan fires when the user navigates away.

Freezed generates the boilerplate (copyWith, equality, pattern matching) so you only define the shape.

  • Create the state file at lib/features/scanner/presentation/bloc/scanner_state.dart:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'scanner_state.freezed.dart';

/// Possible states of the barcode scanner
@freezed
class ScannerState with _$ScannerState {
  /// Scanner is idle, not yet started
  const factory ScannerState.idle() = ScannerIdle;

  /// Camera is active and scanning
  const factory ScannerState.active() = ScannerActive;

  /// A barcode was successfully detected
  const factory ScannerState.result(String value) = ScannerResult;

  /// An error occurred during scanning
  const factory ScannerState.error(String message) = ScannerError;
}
  • Create the BLoC itself at lib/features/scanner/presentation/bloc/scanner_bloc.dart:
import 'dart:async';

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

import 'scanner_event.dart';
import 'scanner_state.dart';

/// Manages the MobileScannerController lifecycle and
/// translates barcode detections into typed states
class ScannerBloc extends Bloc<ScannerEvent, ScannerState> {
  ScannerBloc() : super(const ScannerState.idle()) {
    on<StartScan>(_onStartScan);
    on<ScanDetected>(_onScanDetected);
    on<StopScan>(_onStopScan);
  }

  /// The camera controller managed by this BLoC
  late final MobileScannerController controller = MobileScannerController(
    detectionSpeed: DetectionSpeed.noDuplicates,
    facing: CameraFacing.back,
  );

  StreamSubscription<Object?>? _subscription;

  Future<void> _onStartScan(
    StartScan event,
    Emitter<ScannerState> emit,
  ) async {
    try {
      // Listen to barcode detection stream
      _subscription = controller.barcodes.listen((capture) {
        final barcode = capture.barcodes.firstOrNull;
        if (barcode?.rawValue != null) {
          add(ScannerEvent.scanDetected(barcode!.rawValue!));
        }
      });

      // Start the camera
      await controller.start();
      emit(const ScannerState.active());
    } catch (e) {
      emit(ScannerState.error(e.toString()));
    }
  }

  void _onScanDetected(
    ScanDetected event,
    Emitter<ScannerState> emit,
  ) {
    // Stop listening to prevent duplicate detections
    _subscription?.cancel();
    _subscription = null;
    emit(ScannerState.result(event.value));
  }

  Future<void> _onStopScan(
    StopScan event,
    Emitter<ScannerState> emit,
  ) async {
    _subscription?.cancel();
    _subscription = null;
    await controller.stop();
    emit(const ScannerState.idle());
  }

  @override
  Future<void> close() async {
    _subscription?.cancel();
    await controller.dispose();
    return super.close();
  }
}

What does this code do?

The BLoC owns the MobileScannerController lifecycle. When StartScan fires, it subscribes to the controller.barcodes stream and starts the camera.

On detection, the stream listener dispatches a ScanDetected event. The handler cancels the subscription immediately to prevent duplicate reads, then emits ScannerResult with the scanned value.

The DetectionSpeed.noDuplicates setting ensures the same barcode is not reported repeatedly while still in frame.

  • Save all three files.
  • Run code generation to produce the Freezed output:
flutter pub run build_runner build --delete-conflicting-outputs
  • Confirm the build completes without errors and you see scanner_event.freezed.dart and scanner_state.freezed.dart generated in the bloc folder.

Build runner failing?

Make sure the part directives match the filenames exactly. The part 'scanner_event.freezed.dart' must be in scanner_event.dart, not in the BLoC file.

If you see "Could not resolve", check that mobile_scanner is listed in your pubspec.yaml dependencies and run flutter pub get first.

help me fix build_runner errors in my scanner BLoC

Build the scanner page with camera preview and torch toggle

The scanner page displays a full-screen camera preview with a centered scan window overlay. This guides the inspector to position the barcode within the detection area.

  • Create lib/features/scanner/presentation/pages/scanner_page.dart with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mobile_scanner/mobile_scanner.dart';

import '../bloc/scanner_bloc.dart';
import '../bloc/scanner_event.dart';
import '../bloc/scanner_state.dart';

/// Full-screen barcode/QR scanner with overlay and torch toggle
class ScannerPage extends StatelessWidget {
  const ScannerPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => ScannerBloc()..add(const ScannerEvent.startScan()),
      child: const _ScannerView(),
    );
  }
}

class _ScannerView extends StatelessWidget {
  const _ScannerView();

  @override
  Widget build(BuildContext context) {
    final bloc = context.read<ScannerBloc>();

    return BlocListener<ScannerBloc, ScannerState>(
      listener: (context, state) {
        state.maybeWhen(
          // Pop back with the scanned value
          result: (value) => Navigator.of(context).pop(value),
          orElse: () {},
        );
      },
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Scan Asset'),
          actions: [
            // Torch toggle button
            ValueListenableBuilder(
              valueListenable: bloc.controller,
              builder: (context, state, child) {
                final torchState = state.torchState;
                return IconButton(
                  icon: Icon(
                    torchState == TorchState.on
                        ? Icons.flash_on
                        : Icons.flash_off,
                    color: torchState == TorchState.on
                        ? Colors.yellow
                        : Colors.white,
                  ),
                  onPressed: () => bloc.controller.toggleTorch(),
                );
              },
            ),
          ],
        ),
        body: Stack(
          children: [
            // Camera preview fills the screen
            MobileScanner(
              controller: bloc.controller,
              onDetect: (_) {
                // Detection handled via stream in BLoC
              },
            ),

            // Scan window overlay
            _buildScanOverlay(context),
          ],
        ),
      ),
    );
  }

  /// Draws a semi-transparent overlay with a clear center window
  Widget _buildScanOverlay(BuildContext context) {
    return ColorFiltered(
      colorFilter: ColorFilter.mode(
        Colors.black.withOpacity(0.5),
        BlendMode.srcOut,
      ),
      child: Stack(
        children: [
          // Full overlay background
          Container(
            decoration: const BoxDecoration(
              color: Colors.black,
              backgroundBlendMode: BlendMode.dstOut,
            ),
          ),
          // Clear scan window in the center
          Center(
            child: Container(
              width: 250,
              height: 250,
              decoration: BoxDecoration(
                color: Colors.red, // Color doesn't matter, it gets cut out
                borderRadius: BorderRadius.circular(12),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

What does this code do?

The page creates a ScannerBloc and immediately dispatches StartScan so the camera activates as soon as the page opens.

A BlocListener watches for ScannerResult state. When a barcode is detected, it pops the route with the scanned string value as the navigation result.

The torch toggle uses a ValueListenableBuilder on the controller itself (which is a ValueNotifier) to reactively show the current flash state.

The overlay uses ColorFiltered with BlendMode.srcOut to cut a clear 250x250 window from the darkened background, guiding the user where to point the camera.

  • Save scanner_page.dart.
  • Run the app on your emulator. You cannot test the camera on the emulator, but the build should succeed with no errors.

Testing on a physical device

The Android emulator does not have a real camera. To test barcode scanning, connect a physical Android device via USB and run flutter run. Point the camera at any QR code (even one on your computer screen) to verify detection works.

Camera permission denied on device?

The mobile_scanner plugin handles runtime permission requests automatically on Android. If the permission dialog does not appear, check that your AndroidManifest.xml already has the CAMERA permission from the image_picker integration in the previous step.

help me fix camera permission issues with mobile_scanner on Android

Add the Scan Asset button to the inspection form

The scanner page is ready. Now you need to connect it to the inspection form so inspectors can trigger a scan and have the result flow back into the form state.

  • In lib/features/inspection/presentation/bloc/inspection_form_bloc.dart, add a new Freezed event for receiving the scanned asset ID. Find the events class and add:
/// Asset QR/barcode scanned successfully
const factory InspectionFormEvent.assetScanned(String assetId) = AssetScanned;
  • In the same BLoC file, add a handler for the new event that stores the asset ID in the form state:
Future<void> _onAssetScanned(
  AssetScanned event,
  Emitter<InspectionFormState> emit,
) async {
  // Store scanned asset ID in form state
  emit(state.copyWith(assetId: event.assetId));
}

What does this code do?

The AssetScanned event carries the raw string value from the barcode scanner. The handler simply copies it into the form state's assetId field. The UI reacts to this state change and displays the value as a read-only field.

  • Make sure you also register the handler in the BLoC constructor:
on<AssetScanned>(_onAssetScanned);
  • In lib/features/inspection/presentation/pages/inspection_form_page.dart, add the "Scan Asset" button and the read-only asset ID display. Find the form's widget tree (below the GPS capture section) and add:
// --- Asset QR/Barcode Scanner ---
BlocBuilder<InspectionFormBloc, InspectionFormState>(
  buildWhen: (prev, curr) => prev.assetId != curr.assetId,
  builder: (context, state) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // Scan Asset button
        OutlinedButton.icon(
          onPressed: () async {
            // Navigate to scanner and await result
            final scannedValue = await Navigator.of(context).push<String>(
              MaterialPageRoute(
                builder: (_) => const ScannerPage(),
              ),
            );

            // If a value was returned, update form state
            if (scannedValue != null && context.mounted) {
              context
                  .read<InspectionFormBloc>()
                  .add(InspectionFormEvent.assetScanned(scannedValue));
            }
          },
          icon: const Icon(Icons.qr_code_scanner),
          label: const Text('Scan Asset'),
        ),
        const SizedBox(height: 8),

        // Display scanned asset ID as read-only
        if (state.assetId != null && state.assetId!.isNotEmpty)
          TextFormField(
            initialValue: state.assetId,
            readOnly: true,
            decoration: const InputDecoration(
              labelText: 'Asset ID',
              prefixIcon: Icon(Icons.qr_code),
              border: OutlineInputBorder(),
            ),
          ),
      ],
    );
  },
),

What does this code do?

The button navigates to ScannerPage and awaits the popped result. When the scanner detects a barcode, it pops with the string value. That value is dispatched as an AssetScanned event to the form BLoC.

The buildWhen guard ensures this section only rebuilds when assetId changes. The read-only TextFormField shows the scanned value so the inspector can confirm the correct asset was tagged.

  • Add the import for ScannerPage at the top of inspection_form_page.dart:
import '../../../scanner/presentation/pages/scanner_page.dart';
  • Save all modified files.
  • Run flutter pub run build_runner build --delete-conflicting-outputs to regenerate Freezed code for the updated form event.
  • Run the app on your emulator. Navigate to the inspection form and confirm you see the "Scan Asset" button below the GPS section.

Scan Asset button not appearing?

Check that you placed the BlocBuilder widget inside the form's Column or ListView children, not outside it. The button must be a sibling of the other form fields.

If you get a type error about AssetScanned, make sure you ran build_runner after adding the new event to the Freezed class.

help me debug why my Scan Asset button is not showing in the inspection form

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and run build_runner to generate the Freezed code.

ⓧ I'd like to double check the full code

Here is the complete ScannerBloc file for comparison:

import 'dart:async';

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

import 'scanner_event.dart';
import 'scanner_state.dart';

/// Manages the MobileScannerController lifecycle and
/// translates barcode detections into typed states
class ScannerBloc extends Bloc<ScannerEvent, ScannerState> {
  ScannerBloc() : super(const ScannerState.idle()) {
    on<StartScan>(_onStartScan);
    on<ScanDetected>(_onScanDetected);
    on<StopScan>(_onStopScan);
  }

  /// The camera controller managed by this BLoC
  late final MobileScannerController controller = MobileScannerController(
    detectionSpeed: DetectionSpeed.noDuplicates,
    facing: CameraFacing.back,
  );

  StreamSubscription<Object?>? _subscription;

  Future<void> _onStartScan(
    StartScan event,
    Emitter<ScannerState> emit,
  ) async {
    try {
      // Listen to barcode detection stream
      _subscription = controller.barcodes.listen((capture) {
        final barcode = capture.barcodes.firstOrNull;
        if (barcode?.rawValue != null) {
          add(ScannerEvent.scanDetected(barcode!.rawValue!));
        }
      });

      // Start the camera
      await controller.start();
      emit(const ScannerState.active());
    } catch (e) {
      emit(ScannerState.error(e.toString()));
    }
  }

  void _onScanDetected(
    ScanDetected event,
    Emitter<ScannerState> emit,
  ) {
    // Stop listening to prevent duplicate detections
    _subscription?.cancel();
    _subscription = null;
    emit(ScannerState.result(event.value));
  }

  Future<void> _onStopScan(
    StopScan event,
    Emitter<ScannerState> emit,
  ) async {
    _subscription?.cancel();
    _subscription = null;
    await controller.stop();
    emit(const ScannerState.idle());
  }

  @override
  Future<void> close() async {
    _subscription?.cancel();
    await controller.dispose();
    return super.close();
  }
}

And the complete ScannerPage:

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

import '../bloc/scanner_bloc.dart';
import '../bloc/scanner_event.dart';
import '../bloc/scanner_state.dart';

/// Full-screen barcode/QR scanner with overlay and torch toggle
class ScannerPage extends StatelessWidget {
  const ScannerPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => ScannerBloc()..add(const ScannerEvent.startScan()),
      child: const _ScannerView(),
    );
  }
}

class _ScannerView extends StatelessWidget {
  const _ScannerView();

  @override
  Widget build(BuildContext context) {
    final bloc = context.read<ScannerBloc>();

    return BlocListener<ScannerBloc, ScannerState>(
      listener: (context, state) {
        state.maybeWhen(
          // Pop back with the scanned value
          result: (value) => Navigator.of(context).pop(value),
          orElse: () {},
        );
      },
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Scan Asset'),
          actions: [
            // Torch toggle button
            ValueListenableBuilder(
              valueListenable: bloc.controller,
              builder: (context, state, child) {
                final torchState = state.torchState;
                return IconButton(
                  icon: Icon(
                    torchState == TorchState.on
                        ? Icons.flash_on
                        : Icons.flash_off,
                    color: torchState == TorchState.on
                        ? Colors.yellow
                        : Colors.white,
                  ),
                  onPressed: () => bloc.controller.toggleTorch(),
                );
              },
            ),
          ],
        ),
        body: Stack(
          children: [
            // Camera preview fills the screen
            MobileScanner(
              controller: bloc.controller,
              onDetect: (_) {
                // Detection handled via stream in BLoC
              },
            ),

            // Scan window overlay
            _buildScanOverlay(context),
          ],
        ),
      ),
    );
  }

  /// Draws a semi-transparent overlay with a clear center window
  Widget _buildScanOverlay(BuildContext context) {
    return ColorFiltered(
      colorFilter: ColorFilter.mode(
        Colors.black.withOpacity(0.5),
        BlendMode.srcOut,
      ),
      child: Stack(
        children: [
          // Full overlay background
          Container(
            decoration: const BoxDecoration(
              color: Colors.black,
              backgroundBlendMode: BlendMode.dstOut,
            ),
          ),
          // Clear scan window in the center
          Center(
            child: Container(
              width: 250,
              height: 250,
              decoration: BoxDecoration(
                color: Colors.red,
                borderRadius: BorderRadius.circular(12),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Your inspection app can now scan QR codes and barcodes to tag physical assets. The scanned value links the inspection record to the real-world equipment. Next up, you will add digital signature capture so inspectors can sign off on completed inspections.

Implement Digital Signature Capture

Inspections need a final sign-off to be considered complete. A handwritten digital signature proves that an inspector reviewed and approved the findings.

In this step, you will build a signature capture screen using the hand_signature package, wire it into a BLoC, and connect it to the inspection detail so completed inspections can be signed off and stored locally.

In this step, get ready to:
  • Create a SignatureBloc managing drawing state and PNG export.
  • Build a signature capture page with a drawing canvas, clear button, and save button.
  • Add a Sign Off button to the inspection detail screen and store signature data in the database.
Create the SignatureBloc with Freezed events and states

The signature feature needs its own BLoC to manage three concerns: tracking whether the user is actively drawing, clearing the canvas, and saving the final image as a PNG file.

  • Create the file lib/features/signature/presentation/bloc/signature_event.dart and define the Freezed events:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'signature_event.freezed.dart';

/// Events that drive the signature capture flow
@freezed
class SignatureEvent with _$SignatureEvent {
  /// User started drawing on the canvas
  const factory SignatureEvent.startDrawing() = StartDrawing;

  /// User tapped the clear button
  const factory SignatureEvent.clearSignature() = ClearSignature;

  /// User tapped save with the signer's name
  const factory SignatureEvent.saveSignature(String signerName) = SaveSignature;
}

What does this code do?

Each factory constructor becomes a separate event class after code generation. StartDrawing transitions the UI into drawing mode. ClearSignature resets the canvas. SaveSignature carries the signer's name so the BLoC can persist both the image and the identity.

  • Create lib/features/signature/presentation/bloc/signature_state.dart with the Freezed states:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'signature_state.freezed.dart';

/// States representing the signature capture lifecycle
@freezed
class SignatureState with _$SignatureState {
  /// Initial state before any interaction
  const factory SignatureState.initial() = SignatureInitial;

  /// User is actively drawing on the canvas
  const factory SignatureState.drawing() = SignatureDrawing;

  /// Signature saved successfully to the given file path
  const factory SignatureState.saved(String filePath) = SignatureSaved;

  /// An error occurred during save
  const factory SignatureState.error(String message) = SignatureError;
}
  • Create lib/features/signature/presentation/bloc/signature_bloc.dart with the BLoC logic wrapping HandSignatureControl:
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hand_signature/signature.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';

import 'signature_event.dart';
import 'signature_state.dart';

/// BLoC managing signature drawing lifecycle and PNG export
class SignatureBloc extends Bloc<SignatureEvent, SignatureState> {
  SignatureBloc() : super(const SignatureState.initial()) {
    on<StartDrawing>(_onStartDrawing);
    on<ClearSignature>(_onClearSignature);
    on<SaveSignature>(_onSaveSignature);
  }

  /// The drawing controller shared with the HandSignature widget
  final HandSignatureControl control = HandSignatureControl(
    threshold: 3.0,
    smoothRatio: 0.65,
    velocityRange: 2.0,
  );

  void _onStartDrawing(StartDrawing event, Emitter<SignatureState> emit) {
    emit(const SignatureState.drawing());
  }

  void _onClearSignature(ClearSignature event, Emitter<SignatureState> emit) {
    control.clear();
    emit(const SignatureState.initial());
  }

  Future<void> _onSaveSignature(
    SaveSignature event,
    Emitter<SignatureState> emit,
  ) async {
    try {
      // Export the signature canvas to PNG bytes
      final ByteData? imageData = await control.toImage(
        color: Colors.black,
        background: Colors.white,
        fit: true,
      );

      if (imageData == null) {
        emit(const SignatureState.error('No signature drawn'));
        return;
      }

      // Save PNG bytes to the app documents directory
      final directory = await getApplicationDocumentsDirectory();
      final fileName = '${const Uuid().v4()}.png';
      final filePath = '${directory.path}/$fileName';
      final file = File(filePath);
      await file.writeAsBytes(imageData.buffer.asUint8List());

      emit(SignatureState.saved(filePath));
    } catch (e) {
      emit(SignatureState.error('Failed to save signature: $e'));
    }
  }

  @override
  Future<void> close() {
    control.dispose();
    return super.close();
  }
}

What does this code do?

  • HandSignatureControl manages the cubic Bezier path data as the user draws. The BLoC holds this controller and exposes it to the widget tree.
  • control.toImage() renders the drawn paths to a PNG ByteData object. The fit: true parameter scales the signature to fill the export canvas.
  • The PNG is written to the app documents directory with a UUID filename so each signature is unique and persistent across app restarts.
  • The BLoC disposes the controller in close() to free resources when the signature screen is dismissed.
  • Save all three files.
  • Run code generation to produce the Freezed classes:
flutter pub run build_runner build --delete-conflicting-outputs
  • Confirm the build completes with no errors. You should see generated .freezed.dart files appear beside your event and state files.

Build runner failing?

  • Check that you added the part directive at the top of each file matching the generated filename exactly (e.g. part 'signature_event.freezed.dart';).
  • Make sure freezed_annotation is in your dependencies and freezed is in dev_dependencies.

help me fix build_runner errors for my SignatureBloc Freezed classes

Build the signature capture page

The capture page provides a full-screen canvas where the inspector draws their signature using a finger or stylus. Below the canvas, a text field captures who is signing, and action buttons let them clear or save.

  • Create lib/features/signature/presentation/pages/signature_capture_page.dart:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hand_signature/signature.dart';

import '../bloc/signature_bloc.dart';
import '../bloc/signature_event.dart';
import '../bloc/signature_state.dart';

/// Full-screen signature capture with drawing canvas and save action
class SignatureCapturePage extends StatefulWidget {
  const SignatureCapturePage({super.key});

  @override
  State<SignatureCapturePage> createState() => _SignatureCapturePageState();
}

class _SignatureCapturePageState extends State<SignatureCapturePage> {
  final _nameController = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => SignatureBloc(),
      child: BlocConsumer<SignatureBloc, SignatureState>(
        listener: (context, state) {
          state.maybeWhen(
            saved: (filePath) {
              // Return the file path and signer name to the calling screen
              Navigator.of(context).pop({
                'filePath': filePath,
                'signerName': _nameController.text.trim(),
              });
            },
            error: (message) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(message)),
              );
            },
            orElse: () {},
          );
        },
        builder: (context, state) {
          final bloc = context.read<SignatureBloc>();
          return Scaffold(
            appBar: AppBar(title: const Text('Sign Off')),
            body: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  // Signer name input
                  TextField(
                    controller: _nameController,
                    decoration: const InputDecoration(
                      labelText: 'Signer Name',
                      hintText: 'Enter the name of the person signing',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 16),

                  // Signature drawing canvas
                  Expanded(
                    child: Container(
                      decoration: BoxDecoration(
                        border: Border.all(color: Colors.grey),
                        borderRadius: BorderRadius.circular(8),
                        color: Colors.white,
                      ),
                      child: ClipRRect(
                        borderRadius: BorderRadius.circular(8),
                        child: HandSignature(
                          control: bloc.control,
                          type: SignatureDrawType.shape,
                          onPointerDown: () {
                            bloc.add(const SignatureEvent.startDrawing());
                          },
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 16),

                  // Action buttons row
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      // Clear button
                      OutlinedButton.icon(
                        onPressed: () {
                          bloc.add(const SignatureEvent.clearSignature());
                        },
                        icon: const Icon(Icons.clear),
                        label: const Text('Clear'),
                      ),
                      // Save button
                      FilledButton.icon(
                        onPressed: () {
                          final name = _nameController.text.trim();
                          if (name.isEmpty) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(
                                content: Text('Please enter signer name'),
                              ),
                            );
                            return;
                          }
                          if (!bloc.control.isFilled) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(
                                content: Text('Please draw a signature first'),
                              ),
                            );
                            return;
                          }
                          bloc.add(SignatureEvent.saveSignature(name));
                        },
                        icon: const Icon(Icons.save),
                        label: const Text('Save Signature'),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

What does this code do?

  • HandSignature is the drawing widget from the hand_signature package. Setting type: SignatureDrawType.shape uses the ShapeSignatureDrawer which renders filled paths that mimic an ink pen.
  • onPointerDown fires when the user touches the canvas. This emits StartDrawing to transition the BLoC state.
  • The save button validates that both a name and a drawn signature exist before emitting SaveSignature. The control.isFilled getter checks whether the user has drawn anything.
  • On successful save, the BlocConsumer listener pops the page and returns a map containing the file path and signer name to the calling screen.
  • Save signature_capture_page.dart.
  • Run the app on your emulator. You will not navigate to this page yet, but confirm there are no compile errors.

Import errors for hand_signature?

Make sure hand_signature is listed in your pubspec.yaml dependencies. Run flutter pub get if your IDE shows unresolved imports.

help me resolve hand_signature import issues in my Flutter project

Add the Sign Off button and display the signature

The inspection detail screen is where inspectors review their completed work. A "Sign Off" button should only appear when the inspection status is complete, and once signed, the signature thumbnail displays on this screen.

  • Create lib/features/inspection/presentation/pages/inspection_detail_page.dart (or update it if you already have a placeholder). Add the Sign Off button and signature display:
import 'dart:io';

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

import '../../../signature/presentation/pages/signature_capture_page.dart';
import '../../domain/entities/inspection_entity.dart';

/// Detail screen for a single inspection with sign-off capability
class InspectionDetailPage extends StatefulWidget {
  final InspectionEntity inspection;

  const InspectionDetailPage({super.key, required this.inspection});

  @override
  State<InspectionDetailPage> createState() => _InspectionDetailPageState();
}

class _InspectionDetailPageState extends State<InspectionDetailPage> {
  String? _signaturePath;
  String? _signerName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.inspection.title)),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Inspection metadata
            Text(
              widget.inspection.locationName ?? 'No location',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Chip(label: Text(widget.inspection.category ?? 'General')),
            const SizedBox(height: 16),

            // Checklist items section
            Text(
              'Checklist Items',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            // TODO: Display inspection items with photos

            const SizedBox(height: 24),

            // Signature section
            if (_signaturePath != null) ..._buildSignatureDisplay(),

            // Sign Off button for completed inspections
            if (widget.inspection.status == 'completed' &&
                _signaturePath == null)
              Center(
                child: FilledButton.icon(
                  onPressed: _navigateToSignature,
                  icon: const Icon(Icons.draw),
                  label: const Text('Sign Off'),
                ),
              ),
          ],
        ),
      ),
    );
  }

  /// Navigate to signature capture and handle the result
  Future<void> _navigateToSignature() async {
    final result = await Navigator.of(context).push<Map<String, String>>(
      MaterialPageRoute(
        builder: (_) => const SignatureCapturePage(),
      ),
    );

    if (result != null && mounted) {
      setState(() {
        _signaturePath = result['filePath'];
        _signerName = result['signerName'];
      });

      // Store signature in the database via SignatureDao
      // The dao.insertSignature call goes here with:
      // inspectionId, storagePath (_signaturePath), signerName (_signerName)
    }
  }

  /// Build the signature thumbnail and signer info
  List<Widget> _buildSignatureDisplay() {
    return [
      Text(
        'Signed by: $_signerName',
        style: Theme.of(context).textTheme.titleMedium,
      ),
      const SizedBox(height: 8),
      Container(
        height: 120,
        width: double.infinity,
        decoration: BoxDecoration(
          border: Border.all(color: Colors.grey.shade300),
          borderRadius: BorderRadius.circular(8),
        ),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: Image.file(
            File(_signaturePath!),
            fit: BoxFit.contain,
          ),
        ),
      ),
      const SizedBox(height: 24),
    ];
  }
}

What does this code do?

  • The Sign Off button only renders when status == 'completed' and no signature exists yet. This prevents premature sign-off on draft inspections.
  • _navigateToSignature pushes the SignatureCapturePage and awaits the result map containing the file path and signer name.
  • The signature thumbnail uses Image.file to load the PNG directly from the app documents directory where the BLoC saved it.
  • The TODO comment marks where you will call SignatureDao.insertSignature() to persist the record in the inspection_signatures table. The signature will later be embedded in the PDF report and uploaded to Supabase Storage during sync.
  • Save inspection_detail_page.dart.
  • Hot restart your app on the emulator. Navigate to any completed inspection to confirm the Sign Off button appears.
  • Tap Sign Off, draw a signature on the canvas, enter a name, and tap Save Signature. You should see the signature thumbnail appear on the detail screen.

Signature not displaying after save?

  • Check that the SignatureCapturePage pops with a non-null result. Add a print statement inside the listener to confirm the file path is populated.
  • Make sure path_provider is in your dependencies. Without it, getApplicationDocumentsDirectory() throws a MissingPluginException.
  • Verify you drew something before tapping save. The control.isFilled check prevents empty exports.

help me debug why my signature is not showing up after saving

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that build_runner completed without errors.

ⓧ I'd like to double check the full code

Here is the full signature_bloc.dart:

import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hand_signature/signature.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';

import 'signature_event.dart';
import 'signature_state.dart';

/// BLoC managing signature drawing lifecycle and PNG export
class SignatureBloc extends Bloc<SignatureEvent, SignatureState> {
  SignatureBloc() : super(const SignatureState.initial()) {
    on<StartDrawing>(_onStartDrawing);
    on<ClearSignature>(_onClearSignature);
    on<SaveSignature>(_onSaveSignature);
  }

  /// The drawing controller shared with the HandSignature widget
  final HandSignatureControl control = HandSignatureControl(
    threshold: 3.0,
    smoothRatio: 0.65,
    velocityRange: 2.0,
  );

  void _onStartDrawing(StartDrawing event, Emitter<SignatureState> emit) {
    emit(const SignatureState.drawing());
  }

  void _onClearSignature(ClearSignature event, Emitter<SignatureState> emit) {
    control.clear();
    emit(const SignatureState.initial());
  }

  Future<void> _onSaveSignature(
    SaveSignature event,
    Emitter<SignatureState> emit,
  ) async {
    try {
      // Export the signature canvas to PNG bytes
      final ByteData? imageData = await control.toImage(
        color: Colors.black,
        background: Colors.white,
        fit: true,
      );

      if (imageData == null) {
        emit(const SignatureState.error('No signature drawn'));
        return;
      }

      // Save PNG bytes to the app documents directory
      final directory = await getApplicationDocumentsDirectory();
      final fileName = '${const Uuid().v4()}.png';
      final filePath = '${directory.path}/$fileName';
      final file = File(filePath);
      await file.writeAsBytes(imageData.buffer.asUint8List());

      emit(SignatureState.saved(filePath));
    } catch (e) {
      emit(SignatureState.error('Failed to save signature: $e'));
    }
  }

  @override
  Future<void> close() {
    control.dispose();
    return super.close();
  }
}

Here is the full signature_capture_page.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hand_signature/signature.dart';

import '../bloc/signature_bloc.dart';
import '../bloc/signature_event.dart';
import '../bloc/signature_state.dart';

/// Full-screen signature capture with drawing canvas and save action
class SignatureCapturePage extends StatefulWidget {
  const SignatureCapturePage({super.key});

  @override
  State<SignatureCapturePage> createState() => _SignatureCapturePageState();
}

class _SignatureCapturePageState extends State<SignatureCapturePage> {
  final _nameController = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => SignatureBloc(),
      child: BlocConsumer<SignatureBloc, SignatureState>(
        listener: (context, state) {
          state.maybeWhen(
            saved: (filePath) {
              Navigator.of(context).pop({
                'filePath': filePath,
                'signerName': _nameController.text.trim(),
              });
            },
            error: (message) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(message)),
              );
            },
            orElse: () {},
          );
        },
        builder: (context, state) {
          final bloc = context.read<SignatureBloc>();
          return Scaffold(
            appBar: AppBar(title: const Text('Sign Off')),
            body: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  // Signer name input
                  TextField(
                    controller: _nameController,
                    decoration: const InputDecoration(
                      labelText: 'Signer Name',
                      hintText: 'Enter the name of the person signing',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 16),

                  // Signature drawing canvas
                  Expanded(
                    child: Container(
                      decoration: BoxDecoration(
                        border: Border.all(color: Colors.grey),
                        borderRadius: BorderRadius.circular(8),
                        color: Colors.white,
                      ),
                      child: ClipRRect(
                        borderRadius: BorderRadius.circular(8),
                        child: HandSignature(
                          control: bloc.control,
                          type: SignatureDrawType.shape,
                          onPointerDown: () {
                            bloc.add(const SignatureEvent.startDrawing());
                          },
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 16),

                  // Action buttons row
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      OutlinedButton.icon(
                        onPressed: () {
                          bloc.add(const SignatureEvent.clearSignature());
                        },
                        icon: const Icon(Icons.clear),
                        label: const Text('Clear'),
                      ),
                      FilledButton.icon(
                        onPressed: () {
                          final name = _nameController.text.trim();
                          if (name.isEmpty) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(
                                content: Text('Please enter signer name'),
                              ),
                            );
                            return;
                          }
                          if (!bloc.control.isFilled) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(
                                content: Text('Please draw a signature first'),
                              ),
                            );
                            return;
                          }
                          bloc.add(SignatureEvent.saveSignature(name));
                        },
                        icon: const Icon(Icons.save),
                        label: const Text('Save Signature'),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

Here is the full inspection_detail_page.dart:

import 'dart:io';

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

import '../../../signature/presentation/pages/signature_capture_page.dart';
import '../../domain/entities/inspection_entity.dart';

/// Detail screen for a single inspection with sign-off capability
class InspectionDetailPage extends StatefulWidget {
  final InspectionEntity inspection;

  const InspectionDetailPage({super.key, required this.inspection});

  @override
  State<InspectionDetailPage> createState() => _InspectionDetailPageState();
}

class _InspectionDetailPageState extends State<InspectionDetailPage> {
  String? _signaturePath;
  String? _signerName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.inspection.title)),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Inspection metadata
            Text(
              widget.inspection.locationName ?? 'No location',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Chip(label: Text(widget.inspection.category ?? 'General')),
            const SizedBox(height: 16),

            // Checklist items section
            Text(
              'Checklist Items',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            // TODO: Display inspection items with photos

            const SizedBox(height: 24),

            // Signature section
            if (_signaturePath != null) ..._buildSignatureDisplay(),

            // Sign Off button for completed inspections
            if (widget.inspection.status == 'completed' &&
                _signaturePath == null)
              Center(
                child: FilledButton.icon(
                  onPressed: _navigateToSignature,
                  icon: const Icon(Icons.draw),
                  label: const Text('Sign Off'),
                ),
              ),
          ],
        ),
      ),
    );
  }

  Future<void> _navigateToSignature() async {
    final result = await Navigator.of(context).push<Map<String, String>>(
      MaterialPageRoute(
        builder: (_) => const SignatureCapturePage(),
      ),
    );

    if (result != null && mounted) {
      setState(() {
        _signaturePath = result['filePath'];
        _signerName = result['signerName'];
      });

      // Store signature in the database via SignatureDao
      // The dao.insertSignature call goes here with:
      // inspectionId, storagePath (_signaturePath), signerName (_signerName)
    }
  }

  List<Widget> _buildSignatureDisplay() {
    return [
      Text(
        'Signed by: $_signerName',
        style: Theme.of(context).textTheme.titleMedium,
      ),
      const SizedBox(height: 8),
      Container(
        height: 120,
        width: double.infinity,
        decoration: BoxDecoration(
          border: Border.all(color: Colors.grey.shade300),
          borderRadius: BorderRadius.circular(8),
        ),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: Image.file(
            File(_signaturePath!),
            fit: BoxFit.contain,
          ),
        ),
      ),
      const SizedBox(height: 24),
    ];
  }
}

Your app now captures digital signatures as PNG files and displays them on the inspection detail screen. In the upcoming steps, the signature will be embedded into the generated PDF report and uploaded to Supabase Storage during sync.

Build the Offline Sync Engine

Your app can now capture inspections, photos, signatures, and GPS data. All of it is stored locally in Drift. The missing piece is getting that data to Supabase when the device regains connectivity.

In this step, you will build the sync engine that watches for network changes, processes the sync queue in order, uploads files to Supabase Storage, and pushes data to the cloud. You will also wire up Supabase Realtime so the app receives server-side updates instantly.

In this step, get ready to:
  • Create a ConnectivityService that exposes an online/offline stream.
  • Implement a SyncEngine that processes queued writes, uploads photos and signatures, and retries with exponential backoff.
  • Subscribe to Supabase Realtime for server-side inspection updates.
Create the ConnectivityService and SyncBloc

Before the sync engine can process queued records, it needs to know whether the device is online. The connectivity_plus package exposes a stream of connectivity changes that you will wrap in a service class.

  • Create the file lib/features/sync/data/services/connectivity_service.dart and add the following code:
import 'dart:async';

import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:injectable/injectable.dart';

/// Wraps connectivity_plus to expose a simple online/offline stream.
@LazySingleton()
class ConnectivityService {
  final Connectivity _connectivity = Connectivity();
  late final StreamController<bool> _controller;
  late final StreamSubscription<List<ConnectivityResult>> _subscription;

  ConnectivityService() {
    _controller = StreamController<bool>.broadcast();

    // Listen to platform connectivity changes
    _subscription = _connectivity.onConnectivityChanged.listen((results) {
      // If the list contains only ConnectivityResult.none, we are offline
      final online = !results.contains(ConnectivityResult.none);
      _controller.add(online);
    });
  }

  /// Stream that emits true when online, false when offline.
  Stream<bool> get isOnline => _controller.stream;

  /// One-time check of current connectivity status.
  Future<bool> checkNow() async {
    final results = await _connectivity.checkConnectivity();
    return !results.contains(ConnectivityResult.none);
  }

  /// Clean up resources when no longer needed.
  void dispose() {
    _subscription.cancel();
    _controller.close();
  }
}

What does this code do?

ConnectivityService wraps the connectivity_plus plugin's onConnectivityChanged stream. That stream emits a List<ConnectivityResult> on every change.

When the list contains only ConnectivityResult.none, the device has no network interface active. The service converts this into a simple Stream<bool> for the rest of the app to consume.

Why not just check the interface type?

The connectivity_plus package reports the interface type (WiFi, mobile), not actual Internet reachability. A device on WiFi behind a captive portal still reports as connected. For this project, interface-level detection is enough to demonstrate the sync pattern. A production app would also ping the server to confirm reachability.

Next, create the BLoC that drives the sync status UI.

  • Create the file lib/features/sync/presentation/bloc/sync_bloc.dart and add this code:
import 'dart:async';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';

import '../../data/services/connectivity_service.dart';
import '../../data/services/sync_engine.dart';

part 'sync_bloc.freezed.dart';

// Events
@freezed
class SyncEvent with _$SyncEvent {
  const factory SyncEvent.connectivityChanged({required bool isOnline}) =
      _ConnectivityChanged;
  const factory SyncEvent.syncRequested() = _SyncRequested;
  const factory SyncEvent.syncCompleted() = _SyncCompleted;
  const factory SyncEvent.syncFailed({required String message}) = _SyncFailed;
}

// States
@freezed
class SyncState with _$SyncState {
  const factory SyncState.idle() = SyncIdle;
  const factory SyncState.online() = SyncOnline;
  const factory SyncState.offline() = SyncOffline;
  const factory SyncState.inProgress() = SyncInProgress;
  const factory SyncState.error({required String message}) = SyncError;
}

@injectable
class SyncBloc extends Bloc<SyncEvent, SyncState> {
  final ConnectivityService _connectivityService;
  final SyncEngine _syncEngine;
  late final StreamSubscription<bool> _connectivitySub;

  SyncBloc(this._connectivityService, this._syncEngine)
      : super(const SyncState.idle()) {
    // Listen to connectivity changes
    _connectivitySub = _connectivityService.isOnline.listen((online) {
      add(SyncEvent.connectivityChanged(isOnline: online));
    });

    on<_ConnectivityChanged>(_onConnectivityChanged);
    on<_SyncRequested>(_onSyncRequested);
    on<_SyncCompleted>(_onSyncCompleted);
    on<_SyncFailed>(_onSyncFailed);
  }

  Future<void> _onConnectivityChanged(
    _ConnectivityChanged event,
    Emitter<SyncState> emit,
  ) async {
    if (event.isOnline) {
      emit(const SyncState.online());
      // Trigger sync when connectivity returns
      add(const SyncEvent.syncRequested());
    } else {
      emit(const SyncState.offline());
    }
  }

  Future<void> _onSyncRequested(
    _SyncRequested event,
    Emitter<SyncState> emit,
  ) async {
    emit(const SyncState.inProgress());
    try {
      await _syncEngine.processQueue();
      add(const SyncEvent.syncCompleted());
    } catch (e) {
      add(SyncEvent.syncFailed(message: e.toString()));
    }
  }

  void _onSyncCompleted(_SyncCompleted event, Emitter<SyncState> emit) {
    emit(const SyncState.online());
  }

  void _onSyncFailed(_SyncFailed event, Emitter<SyncState> emit) {
    emit(SyncState.error(message: event.message));
  }

  @override
  Future<void> close() {
    _connectivitySub.cancel();
    return super.close();
  }
}

What does SyncBloc do?

SyncBloc subscribes to ConnectivityService.isOnline and translates connectivity changes into sync actions. When the device comes back online, the BLoC automatically triggers SyncEngine.processQueue().

The state drives the app bar indicator: SyncOnline shows a green dot, SyncOffline shows a red dot, and SyncInProgress shows a spinning indicator.

  • Add the sync status indicator to your app bar in lib/features/inspection/presentation/pages/inspection_list_page.dart by adding a BlocBuilder<SyncBloc, SyncState> widget in the actions list of the AppBar:
  • Return a green Icon(Icons.cloud_done) for SyncOnline.
  • Return a red Icon(Icons.cloud_off) for SyncOffline.
  • Return a SizedBox with a CircularProgressIndicator for SyncInProgress.
  • Run flutter pub run build_runner build to generate the Freezed code for SyncBloc.
  • Save all files and confirm the app compiles without errors.

Build runner failing on SyncBloc?

Make sure the part 'sync_bloc.freezed.dart'; directive is present at the top of the file. Also confirm that SyncEngine exists (you will create it in the next substep). If the import fails, create an empty placeholder class first so the build can complete.

help me fix build_runner errors for my SyncBloc

Implement the SyncEngine with file uploads and exponential backoff

The SyncEngine is the core of your offline-first architecture. It reads entries from the SyncQueue table in FIFO order, uploads associated files to Supabase Storage, and upserts record data to the cloud.

  • Create the file lib/features/sync/data/services/sync_engine.dart and add the following code:
import 'dart:io';

import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import '../../../../core/database/app_database.dart';
import '../../../../core/logging/app_logger.dart';

/// Processes the local SyncQueue and pushes data to Supabase.
@LazySingleton()
class SyncEngine {
  final AppDatabase _db;
  final SupabaseClient _supabase;
  final AppLogger _logger;

  // Retry configuration
  static const int _maxRetries = 5;
  static const List<Duration> _backoffDelays = [
    Duration(seconds: 2),
    Duration(seconds: 4),
    Duration(seconds: 8),
    Duration(seconds: 8),
    Duration(seconds: 8),
  ];

  SyncEngine(this._db, this._supabase, this._logger);

  /// Process all pending entries in FIFO order.
  Future<void> processQueue() async {
    final entries = await _db.syncQueueDao.getPendingEntries();
    _logger.info('SyncEngine: processing ${entries.length} queued entries');

    for (final entry in entries) {
      await _processEntry(entry);
    }
  }

  Future<void> _processEntry(SyncQueueEntry entry) async {
    if (entry.retryCount >= _maxRetries) {
      // Mark as permanently failed
      await _db.syncQueueDao.markFailed(entry.id);
      _logger.warning('SyncEngine: entry ${entry.id} exceeded max retries');
      return;
    }

    try {
      switch (entry.tableName) {
        case 'inspections':
          await _syncInspection(entry);
          break;
        case 'inspection_items':
          await _syncInspectionItem(entry);
          break;
        case 'inspection_photos':
          await _syncPhoto(entry);
          break;
        case 'inspection_signatures':
          await _syncSignature(entry);
          break;
      }

      // Mark as synced on success
      await _db.syncQueueDao.markSynced(entry.id);
      _logger.info('SyncEngine: synced entry ${entry.id}');
    } catch (e) {
      // Increment retry count and wait with exponential backoff
      final delay = _backoffDelays[entry.retryCount];
      _logger.warning(
        'SyncEngine: entry ${entry.id} failed, retry ${entry.retryCount + 1}',
      );
      await Future.delayed(delay);
      await _db.syncQueueDao.incrementRetry(entry.id);
    }
  }

  /// Upsert inspection record to Supabase using last-write-wins.
  Future<void> _syncInspection(SyncQueueEntry entry) async {
    final data = entry.payload;
    await _supabase.from('inspections').upsert(
      data,
      onConflict: 'id',
    );
  }

  /// Upsert inspection item to Supabase.
  Future<void> _syncInspectionItem(SyncQueueEntry entry) async {
    final data = entry.payload;
    await _supabase.from('inspection_items').upsert(
      data,
      onConflict: 'id',
    );
  }

  /// Upload photo file to Storage, then upsert the photo record.
  Future<void> _syncPhoto(SyncQueueEntry entry) async {
    final data = entry.payload;
    final localPath = data['local_path'] as String;
    final userId = _supabase.auth.currentUser!.id;
    final photoId = data['id'] as String;

    // Upload to inspection-photos bucket: {userId}/{photoId}.jpg
    final storagePath = '$userId/$photoId.jpg';
    final file = File(localPath);

    await _supabase.storage.from('inspection-photos').upload(
      storagePath,
      file,
      fileOptions: const FileOptions(upsert: true),
    );

    // Update the record with the storage path
    data['storage_path'] = storagePath;
    data.remove('local_path');

    await _supabase.from('inspection_photos').upsert(
      data,
      onConflict: 'id',
    );
  }

  /// Upload signature PNG to Storage, then upsert the signature record.
  Future<void> _syncSignature(SyncQueueEntry entry) async {
    final data = entry.payload;
    final localPath = data['local_path'] as String;
    final userId = _supabase.auth.currentUser!.id;
    final sigId = data['id'] as String;

    // Upload to inspection-signatures bucket: {userId}/{sigId}.png
    final storagePath = '$userId/$sigId.png';
    final file = File(localPath);

    await _supabase.storage.from('inspection-signatures').upload(
      storagePath,
      file,
      fileOptions: const FileOptions(upsert: true),
    );

    // Update the record with the storage path
    data['storage_path'] = storagePath;
    data.remove('local_path');

    await _supabase.from('inspection_signatures').upsert(
      data,
      onConflict: 'id',
    );
  }
}

What does the SyncEngine do?

The engine reads pending SyncQueue entries from Drift in FIFO order (oldest first). For each entry, it checks the table name to decide which sync path to follow.

Photos upload to the inspection-photos bucket at path {userId}/{photoId}.jpg. Signatures upload to inspection-signatures at {userId}/{sigId}.png. Inspection and item records upsert directly into Supabase tables.

Conflict resolution uses last-write-wins. The updatedAt timestamp in each record determines which version survives. The upsert call with onConflict: 'id' overwrites the existing row if the ID matches.

If a sync call fails, the engine waits using exponential backoff (2s, 4s, 8s) and increments the retry counter. After 5 failed attempts, the entry is marked as permanently failed so it does not block the rest of the queue.

Why last-write-wins?

In a field inspection use case, each device belongs to one inspector. Inspections are rarely edited concurrently by multiple users. Last-write-wins via updatedAt is the simplest conflict resolution that works reliably for this domain. More complex strategies (merge, CRDT) add cost without benefiting single-user-per-device workflows.

  • Save sync_engine.dart.
  • Register both services in your DI container by running flutter pub run build_runner build. The @LazySingleton() annotations on both classes will be picked up by Injectable.
  • In your terminal, run your app on the emulator and toggle airplane mode. You should see the app bar indicator switch between green (online) and red (offline).

Indicator not changing when you toggle airplane mode?

Make sure you wrapped the inspection list page in a BlocProvider<SyncBloc> and that GetIt resolves SyncBloc correctly. Also confirm ConnectivityService is registered as a @LazySingleton and not recreated on each resolve.

help me debug why my sync status indicator is not updating

Add Supabase Realtime for server-side updates

Sync is not just about pushing data up. A supervisor might change an inspection's status from the Supabase dashboard (for example, approving or rejecting it). Your app needs to pull those changes down in real time.

  • Open lib/features/sync/data/services/sync_engine.dart and add the following method to the SyncEngine class:
  /// Subscribe to real-time UPDATE events on the inspections table.
  void subscribeToRealtimeUpdates({
    required void Function(Map<String, dynamic> newRecord) onUpdate,
  }) {
    _supabase
        .channel('inspections-updates')
        .onPostgresChanges(
          event: PostgresChangeEvent.update,
          schema: 'public',
          table: 'inspections',
          callback: (payload) {
            final newRecord = payload.newRecord;
            _logger.info(
              'Realtime: inspection ${newRecord['id']} updated',
            );
            // Update local Drift database
            _updateLocalInspection(newRecord);
            // Notify the UI
            onUpdate(newRecord);
          },
        )
        .subscribe();
  }

  /// Write the server-side change into the local Drift database.
  Future<void> _updateLocalInspection(Map<String, dynamic> record) async {
    await _db.inspectionDao.updateFromServer(record);
  }

What does this Realtime subscription do?

The .channel('inspections-updates') call creates a named Realtime channel. The .onPostgresChanges() method filters for UPDATE events on the inspections table in the public schema.

When a supervisor changes a status (e.g., from draft to approved), Supabase pushes the new record payload over WebSocket. The callback writes that change into the local Drift database and fires the onUpdate callback so the UI can show a snackbar notification.

  • Call subscribeToRealtimeUpdates from your SyncBloc constructor or from the initial app bootstrap after the user is authenticated. Pass an onUpdate callback that triggers a snackbar using a global ScaffoldMessenger key or a dedicated event.
  • Save all files and run flutter pub run build_runner build one more time to regenerate DI.
  • To test, run the app on the emulator, create an inspection, then open your Supabase dashboard and manually change that inspection's status column. You should see a snackbar appear in the app confirming the status change.

Not receiving Realtime events?

Confirm that you enabled Realtime on the inspections table in your Supabase dashboard (Database > Replication > toggle the table on). Also ensure your RLS policy allows SELECT for the authenticated user. Realtime respects RLS.

help me debug why Supabase Realtime events are not reaching my Flutter app

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: connectivity_service.dart, sync_engine.dart, sync_bloc.dart, and the updated inspection_list_page.dart.

ⓧ I'd like to double check the full code

Here is the full connectivity_service.dart:

import 'dart:async';

import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:injectable/injectable.dart';

/// Wraps connectivity_plus to expose a simple online/offline stream.
@LazySingleton()
class ConnectivityService {
  final Connectivity _connectivity = Connectivity();
  late final StreamController<bool> _controller;
  late final StreamSubscription<List<ConnectivityResult>> _subscription;

  ConnectivityService() {
    _controller = StreamController<bool>.broadcast();

    // Listen to platform connectivity changes
    _subscription = _connectivity.onConnectivityChanged.listen((results) {
      // If the list contains only ConnectivityResult.none, we are offline
      final online = !results.contains(ConnectivityResult.none);
      _controller.add(online);
    });
  }

  /// Stream that emits true when online, false when offline.
  Stream<bool> get isOnline => _controller.stream;

  /// One-time check of current connectivity status.
  Future<bool> checkNow() async {
    final results = await _connectivity.checkConnectivity();
    return !results.contains(ConnectivityResult.none);
  }

  /// Clean up resources when no longer needed.
  void dispose() {
    _subscription.cancel();
    _controller.close();
  }
}

Here is the full sync_engine.dart:

import 'dart:io';

import 'package:injectable/injectable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import '../../../../core/database/app_database.dart';
import '../../../../core/logging/app_logger.dart';

/// Processes the local SyncQueue and pushes data to Supabase.
@LazySingleton()
class SyncEngine {
  final AppDatabase _db;
  final SupabaseClient _supabase;
  final AppLogger _logger;

  // Retry configuration
  static const int _maxRetries = 5;
  static const List<Duration> _backoffDelays = [
    Duration(seconds: 2),
    Duration(seconds: 4),
    Duration(seconds: 8),
    Duration(seconds: 8),
    Duration(seconds: 8),
  ];

  SyncEngine(this._db, this._supabase, this._logger);

  /// Process all pending entries in FIFO order.
  Future<void> processQueue() async {
    final entries = await _db.syncQueueDao.getPendingEntries();
    _logger.info('SyncEngine: processing ${entries.length} queued entries');

    for (final entry in entries) {
      await _processEntry(entry);
    }
  }

  Future<void> _processEntry(SyncQueueEntry entry) async {
    if (entry.retryCount >= _maxRetries) {
      // Mark as permanently failed
      await _db.syncQueueDao.markFailed(entry.id);
      _logger.warning('SyncEngine: entry ${entry.id} exceeded max retries');
      return;
    }

    try {
      switch (entry.tableName) {
        case 'inspections':
          await _syncInspection(entry);
          break;
        case 'inspection_items':
          await _syncInspectionItem(entry);
          break;
        case 'inspection_photos':
          await _syncPhoto(entry);
          break;
        case 'inspection_signatures':
          await _syncSignature(entry);
          break;
      }

      // Mark as synced on success
      await _db.syncQueueDao.markSynced(entry.id);
      _logger.info('SyncEngine: synced entry ${entry.id}');
    } catch (e) {
      // Increment retry count and wait with exponential backoff
      final delay = _backoffDelays[entry.retryCount];
      _logger.warning(
        'SyncEngine: entry ${entry.id} failed, retry ${entry.retryCount + 1}',
      );
      await Future.delayed(delay);
      await _db.syncQueueDao.incrementRetry(entry.id);
    }
  }

  /// Upsert inspection record to Supabase using last-write-wins.
  Future<void> _syncInspection(SyncQueueEntry entry) async {
    final data = entry.payload;
    await _supabase.from('inspections').upsert(
      data,
      onConflict: 'id',
    );
  }

  /// Upsert inspection item to Supabase.
  Future<void> _syncInspectionItem(SyncQueueEntry entry) async {
    final data = entry.payload;
    await _supabase.from('inspection_items').upsert(
      data,
      onConflict: 'id',
    );
  }

  /// Upload photo file to Storage, then upsert the photo record.
  Future<void> _syncPhoto(SyncQueueEntry entry) async {
    final data = entry.payload;
    final localPath = data['local_path'] as String;
    final userId = _supabase.auth.currentUser!.id;
    final photoId = data['id'] as String;

    // Upload to inspection-photos bucket: {userId}/{photoId}.jpg
    final storagePath = '$userId/$photoId.jpg';
    final file = File(localPath);

    await _supabase.storage.from('inspection-photos').upload(
      storagePath,
      file,
      fileOptions: const FileOptions(upsert: true),
    );

    // Update the record with the storage path
    data['storage_path'] = storagePath;
    data.remove('local_path');

    await _supabase.from('inspection_photos').upsert(
      data,
      onConflict: 'id',
    );
  }

  /// Upload signature PNG to Storage, then upsert the signature record.
  Future<void> _syncSignature(SyncQueueEntry entry) async {
    final data = entry.payload;
    final localPath = data['local_path'] as String;
    final userId = _supabase.auth.currentUser!.id;
    final sigId = data['id'] as String;

    // Upload to inspection-signatures bucket: {userId}/{sigId}.png
    final storagePath = '$userId/$sigId.png';
    final file = File(localPath);

    await _supabase.storage.from('inspection-signatures').upload(
      storagePath,
      file,
      fileOptions: const FileOptions(upsert: true),
    );

    // Update the record with the storage path
    data['storage_path'] = storagePath;
    data.remove('local_path');

    await _supabase.from('inspection_signatures').upsert(
      data,
      onConflict: 'id',
    );
  }

  /// Subscribe to real-time UPDATE events on the inspections table.
  void subscribeToRealtimeUpdates({
    required void Function(Map<String, dynamic> newRecord) onUpdate,
  }) {
    _supabase
        .channel('inspections-updates')
        .onPostgresChanges(
          event: PostgresChangeEvent.update,
          schema: 'public',
          table: 'inspections',
          callback: (payload) {
            final newRecord = payload.newRecord;
            _logger.info(
              'Realtime: inspection ${newRecord['id']} updated',
            );
            // Update local Drift database
            _updateLocalInspection(newRecord);
            // Notify the UI
            onUpdate(newRecord);
          },
        )
        .subscribe();
  }

  /// Write the server-side change into the local Drift database.
  Future<void> _updateLocalInspection(Map<String, dynamic> record) async {
    await _db.inspectionDao.updateFromServer(record);
  }
}

Here is the full sync_bloc.dart:

import 'dart:async';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';

import '../../data/services/connectivity_service.dart';
import '../../data/services/sync_engine.dart';

part 'sync_bloc.freezed.dart';

// Events
@freezed
class SyncEvent with _$SyncEvent {
  const factory SyncEvent.connectivityChanged({required bool isOnline}) =
      _ConnectivityChanged;
  const factory SyncEvent.syncRequested() = _SyncRequested;
  const factory SyncEvent.syncCompleted() = _SyncCompleted;
  const factory SyncEvent.syncFailed({required String message}) = _SyncFailed;
}

// States
@freezed
class SyncState with _$SyncState {
  const factory SyncState.idle() = SyncIdle;
  const factory SyncState.online() = SyncOnline;
  const factory SyncState.offline() = SyncOffline;
  const factory SyncState.inProgress() = SyncInProgress;
  const factory SyncState.error({required String message}) = SyncError;
}

@injectable
class SyncBloc extends Bloc<SyncEvent, SyncState> {
  final ConnectivityService _connectivityService;
  final SyncEngine _syncEngine;
  late final StreamSubscription<bool> _connectivitySub;

  SyncBloc(this._connectivityService, this._syncEngine)
      : super(const SyncState.idle()) {
    // Listen to connectivity changes
    _connectivitySub = _connectivityService.isOnline.listen((online) {
      add(SyncEvent.connectivityChanged(isOnline: online));
    });

    on<_ConnectivityChanged>(_onConnectivityChanged);
    on<_SyncRequested>(_onSyncRequested);
    on<_SyncCompleted>(_onSyncCompleted);
    on<_SyncFailed>(_onSyncFailed);
  }

  Future<void> _onConnectivityChanged(
    _ConnectivityChanged event,
    Emitter<SyncState> emit,
  ) async {
    if (event.isOnline) {
      emit(const SyncState.online());
      // Trigger sync when connectivity returns
      add(const SyncEvent.syncRequested());
    } else {
      emit(const SyncState.offline());
    }
  }

  Future<void> _onSyncRequested(
    _SyncRequested event,
    Emitter<SyncState> emit,
  ) async {
    emit(const SyncState.inProgress());
    try {
      await _syncEngine.processQueue();
      add(const SyncEvent.syncCompleted());
    } catch (e) {
      add(SyncEvent.syncFailed(message: e.toString()));
    }
  }

  void _onSyncCompleted(_SyncCompleted event, Emitter<SyncState> emit) {
    emit(const SyncState.online());
  }

  void _onSyncFailed(_SyncFailed event, Emitter<SyncState> emit) {
    emit(SyncState.error(message: event.message));
  }

  @override
  Future<void> close() {
    _connectivitySub.cancel();
    return super.close();
  }
}

Your offline sync engine is live. Data queued while offline flows to Supabase automatically when connectivity returns, and server-side changes push back to the device in real time. Next up, you will generate professional PDF reports from inspection data.

Generate PDF Reports

Your sync engine handles getting data to and from the cloud. Now inspectors need something tangible to hand off at the end of an inspection.

In this step, you will build a PDF report generator that transforms raw inspection data into a professional multi-page document. The report includes severity-coded findings, embedded photos, a digital signature, and a clickable map link.

In this step, get ready to:
  • Create a ReportGenerator service that produces a multi-page A4 PDF with header, location, findings table, photos, and signature.
  • Add a Generate Report button to the inspection detail page that opens the printing preview.
  • Implement batch report generation from the inspection list with multi-select.
Create the ReportGenerator service

The pdf package uses a widget system similar to Flutter itself. You compose pages from pw.Text, pw.Table, pw.Image, and layout widgets like pw.Column and pw.Row.

  • Create the file lib/features/report/data/services/report_generator.dart.
  • Add the following code to build the PDF generation service:
import 'dart:io';
import 'dart:typed_data';

import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:injectable/injectable.dart';
import 'package:intl/intl.dart';

import '../../../inspection/domain/entities/inspection_entity.dart';
import '../../../inspection/domain/entities/inspection_item_entity.dart';
import '../../../inspection/domain/entities/inspection_photo_entity.dart';
import '../../../signature/domain/entities/signature_entity.dart';

@lazySingleton
class ReportGenerator {
  /// Generates a PDF report for a single inspection
  Future<Uint8List> generateReport({
    required InspectionEntity inspection,
    required List<InspectionItemEntity> items,
    required List<InspectionPhotoEntity> photos,
    SignatureEntity? signature,
    String? inspectorName,
  }) async {
    final pdf = pw.Document();

    // Build the multi-page A4 document
    pdf.addPage(
      pw.MultiPage(
        pageFormat: PdfPageFormat.a4,
        header: (context) => _buildHeader(inspection, inspectorName),
        footer: (context) => _buildFooter(context),
        build: (context) => [
          _buildLocationSection(inspection),
          pw.SizedBox(height: 20),
          _buildFindingsTable(items),
          pw.SizedBox(height: 20),
          ..._buildPhotosSection(photos),
          if (signature != null) ..._buildSignatureSection(signature),
          pw.SizedBox(height: 20),
          _buildStatusSummary(inspection, items),
        ],
      ),
    );

    return pdf.save();
  }

  /// Header section: title, date, inspector name, category
  pw.Widget _buildHeader(
    InspectionEntity inspection,
    String? inspectorName,
  ) {
    final dateFormat = DateFormat('MMMM d, yyyy - HH:mm');
    return pw.Container(
      padding: const pw.EdgeInsets.only(bottom: 12),
      decoration: const pw.BoxDecoration(
        border: pw.Border(
          bottom: pw.BorderSide(width: 2, color: PdfColors.indigo),
        ),
      ),
      child: pw.Column(
        crossAxisAlignment: pw.CrossAxisAlignment.start,
        children: [
          pw.Text(
            inspection.title,
            style: pw.TextStyle(
              fontSize: 22,
              fontWeight: pw.FontWeight.bold,
              color: PdfColors.indigo,
            ),
          ),
          pw.SizedBox(height: 4),
          pw.Row(
            mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
            children: [
              pw.Text(
                'Inspector: ${inspectorName ?? "Unknown"}',
                style: const pw.TextStyle(fontSize: 11),
              ),
              pw.Text(
                dateFormat.format(inspection.createdAt),
                style: const pw.TextStyle(fontSize: 11),
              ),
            ],
          ),
          pw.SizedBox(height: 2),
          pw.Text(
            'Category: ${inspection.category}',
            style: pw.TextStyle(
              fontSize: 11,
              fontWeight: pw.FontWeight.bold,
            ),
          ),
        ],
      ),
    );
  }

  /// Footer with page numbers
  pw.Widget _buildFooter(pw.Context context) {
    return pw.Container(
      alignment: pw.Alignment.centerRight,
      margin: const pw.EdgeInsets.only(top: 8),
      child: pw.Text(
        'Page ${context.pageNumber} of ${context.pagesCount}',
        style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600),
      ),
    );
  }

  /// Location section with clickable Google Maps link
  pw.Widget _buildLocationSection(InspectionEntity inspection) {
    final hasCoordinates =
        inspection.latitude != null && inspection.longitude != null;
    final mapsUrl = hasCoordinates
        ? 'https://www.google.com/maps?q=${inspection.latitude},${inspection.longitude}'
        : null;

    return pw.Container(
      padding: const pw.EdgeInsets.all(10),
      decoration: pw.BoxDecoration(
        color: PdfColors.grey100,
        borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
      ),
      child: pw.Column(
        crossAxisAlignment: pw.CrossAxisAlignment.start,
        children: [
          pw.Text(
            'Location',
            style: pw.TextStyle(
              fontSize: 14,
              fontWeight: pw.FontWeight.bold,
            ),
          ),
          pw.SizedBox(height: 4),
          pw.Text(
            inspection.locationName ?? 'No location specified',
            style: const pw.TextStyle(fontSize: 11),
          ),
          if (hasCoordinates) ...[            pw.SizedBox(height: 4),
            pw.Text(
              'Coordinates: ${inspection.latitude}, ${inspection.longitude}',
              style: const pw.TextStyle(fontSize: 10),
            ),
            pw.SizedBox(height: 2),
            pw.UrlLink(
              destination: mapsUrl!,
              child: pw.Text(
                'View on Google Maps',
                style: pw.TextStyle(
                  fontSize: 10,
                  color: PdfColors.blue,
                  decoration: pw.TextDecoration.underline,
                ),
              ),
            ),
          ],
        ],
      ),
    );
  }

  /// Findings table with severity color coding
  pw.Widget _buildFindingsTable(List<InspectionItemEntity> items) {
    return pw.Column(
      crossAxisAlignment: pw.CrossAxisAlignment.start,
      children: [
        pw.Text(
          'Findings',
          style: pw.TextStyle(
            fontSize: 14,
            fontWeight: pw.FontWeight.bold,
          ),
        ),
        pw.SizedBox(height: 8),
        pw.Table(
          border: pw.TableBorder.all(color: PdfColors.grey300),
          columnWidths: {
            0: const pw.FlexColumnWidth(3),
            1: const pw.FlexColumnWidth(1),
            2: const pw.FlexColumnWidth(1),
            3: const pw.FlexColumnWidth(2),
          },
          children: [
            // Table header row
            pw.TableRow(
              decoration: const pw.BoxDecoration(color: PdfColors.grey200),
              children: [
                _tableCell('Description', isHeader: true),
                _tableCell('Severity', isHeader: true),
                _tableCell('Status', isHeader: true),
                _tableCell('Notes', isHeader: true),
              ],
            ),
            // Data rows with severity color coding
            ...items.map((item) => pw.TableRow(
              children: [
                _tableCell(item.description),
                _severityCell(item.severity),
                _tableCell(item.isCompleted ? 'Done' : 'Pending'),
                _tableCell(item.notes ?? '-'),
              ],
            )),
          ],
        ),
      ],
    );
  }

  /// Helper for standard table cells
  pw.Widget _tableCell(String text, {bool isHeader = false}) {
    return pw.Padding(
      padding: const pw.EdgeInsets.all(6),
      child: pw.Text(
        text,
        style: pw.TextStyle(
          fontSize: 9,
          fontWeight: isHeader ? pw.FontWeight.bold : pw.FontWeight.normal,
        ),
      ),
    );
  }

  /// Severity cell with color coding
  pw.Widget _severityCell(String severity) {
    final color = _severityColor(severity);
    return pw.Container(
      padding: const pw.EdgeInsets.all(6),
      color: color.withValues(alpha: 0.2),
      child: pw.Text(
        severity.toUpperCase(),
        style: pw.TextStyle(
          fontSize: 9,
          fontWeight: pw.FontWeight.bold,
          color: color,
        ),
      ),
    );
  }

  /// Maps severity strings to PDF colors
  PdfColor _severityColor(String severity) {
    switch (severity.toLowerCase()) {
      case 'low':
        return PdfColors.green;
      case 'medium':
        return PdfColors.yellow800;
      case 'high':
        return PdfColors.orange;
      case 'critical':
        return PdfColors.red;
      default:
        return PdfColors.grey;
    }
  }

  /// Photos section: embeds images from local file paths
  List<pw.Widget> _buildPhotosSection(List<InspectionPhotoEntity> photos) {
    if (photos.isEmpty) return [];

    return [
      pw.Text(
        'Photos',
        style: pw.TextStyle(
          fontSize: 14,
          fontWeight: pw.FontWeight.bold,
        ),
      ),
      pw.SizedBox(height: 8),
      pw.Wrap(
        spacing: 8,
        runSpacing: 8,
        children: photos.map((photo) {
          final file = File(photo.storagePath);
          if (!file.existsSync()) {
            return pw.Container(
              width: 150,
              height: 100,
              color: PdfColors.grey300,
              child: pw.Center(
                child: pw.Text('Photo unavailable', style: const pw.TextStyle(fontSize: 8)),
              ),
            );
          }
          final image = pw.MemoryImage(file.readAsBytesSync());
          return pw.Container(
            width: 150,
            height: 100,
            child: pw.Column(
              children: [
                pw.Expanded(
                  child: pw.Image(image, fit: pw.BoxFit.cover),
                ),
                if (photo.caption != null)
                  pw.Text(
                    photo.caption!,
                    style: const pw.TextStyle(fontSize: 8),
                  ),
              ],
            ),
          );
        }).toList(),
      ),
      pw.SizedBox(height: 20),
    ];
  }

  /// Signature section: embeds the saved signature PNG
  List<pw.Widget> _buildSignatureSection(SignatureEntity signature) {
    final file = File(signature.storagePath);
    if (!file.existsSync()) return [];

    final image = pw.MemoryImage(file.readAsBytesSync());
    return [
      pw.SizedBox(height: 20),
      pw.Text(
        'Sign-Off',
        style: pw.TextStyle(
          fontSize: 14,
          fontWeight: pw.FontWeight.bold,
        ),
      ),
      pw.SizedBox(height: 8),
      pw.Row(
        children: [
          pw.Container(
            width: 200,
            height: 80,
            decoration: pw.BoxDecoration(
              border: pw.Border.all(color: PdfColors.grey400),
            ),
            child: pw.Image(image, fit: pw.BoxFit.contain),
          ),
          pw.SizedBox(width: 12),
          pw.Text(
            'Signed by: ${signature.signerName}',
            style: const pw.TextStyle(fontSize: 11),
          ),
        ],
      ),
    ];
  }

  /// Status summary at the bottom
  pw.Widget _buildStatusSummary(
    InspectionEntity inspection,
    List<InspectionItemEntity> items,
  ) {
    final completed = items.where((i) => i.isCompleted).length;
    final total = items.length;

    return pw.Container(
      padding: const pw.EdgeInsets.all(10),
      decoration: pw.BoxDecoration(
        color: PdfColors.indigo50,
        borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
      ),
      child: pw.Row(
        mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
        children: [
          pw.Text(
            'Status: ${inspection.status.toUpperCase()}',
            style: pw.TextStyle(
              fontSize: 12,
              fontWeight: pw.FontWeight.bold,
            ),
          ),
          pw.Text(
            'Items completed: $completed / $total',
            style: const pw.TextStyle(fontSize: 11),
          ),
        ],
      ),
    );
  }

  /// Generates a combined PDF for multiple inspections (batch mode)
  Future<Uint8List> generateBatchReport({
    required List<InspectionEntity> inspections,
    required Map<String, List<InspectionItemEntity>> itemsMap,
    required Map<String, List<InspectionPhotoEntity>> photosMap,
    required Map<String, SignatureEntity?> signaturesMap,
    String? inspectorName,
  }) async {
    final pdf = pw.Document();

    // Each inspection gets its own multi-page section
    for (final inspection in inspections) {
      final items = itemsMap[inspection.id] ?? [];
      final photos = photosMap[inspection.id] ?? [];
      final signature = signaturesMap[inspection.id];

      pdf.addPage(
        pw.MultiPage(
          pageFormat: PdfPageFormat.a4,
          header: (context) => _buildHeader(inspection, inspectorName),
          footer: (context) => _buildFooter(context),
          build: (context) => [
            _buildLocationSection(inspection),
            pw.SizedBox(height: 20),
            _buildFindingsTable(items),
            pw.SizedBox(height: 20),
            ..._buildPhotosSection(photos),
            if (signature != null) ..._buildSignatureSection(signature),
            pw.SizedBox(height: 20),
            _buildStatusSummary(inspection, items),
          ],
        ),
      );
    }

    return pdf.save();
  }
}

What does this code do?

The ReportGenerator builds a multi-page A4 document using pw.MultiPage. This widget automatically flows content across pages and handles page breaks when items don't fit.

  • The header repeats on every page with the inspection title, inspector name, date, and category.
  • The location section creates a clickable pw.UrlLink that opens Google Maps at the inspection's GPS coordinates.
  • The findings table color-codes each severity level: green for low, yellow for medium, orange for high, red for critical.
  • Photos are loaded from local file paths using pw.MemoryImage and embedded directly into the PDF.
  • The generateBatchReport method combines multiple inspections into one document, each starting on a fresh page section.
  • Save report_generator.dart.
  • Run code generation to ensure the DI picks up the new @lazySingleton annotation by running:
flutter pub run build_runner build --delete-conflicting-outputs

You should see the build complete with no errors and the injection config regenerated.

Build runner failing?

Check that all import paths reference existing entity files. The entity imports at the top of report_generator.dart must match your actual file structure from the domain layer you built in Step 6.

help me fix build_runner errors in my report_generator.dart file

Add a Generate Report button to the inspection detail page

Inspectors need a one-tap way to preview and share their report. The printing package provides Printing.layoutPdf which opens the platform's native print/share dialog.

  • Open lib/features/inspection/presentation/pages/inspection_detail_page.dart.
  • Find the existing 'Sign Off' button section and add a 'Generate Report' button below it. Copy the following code:
import 'package:printing/printing.dart';
import 'package:pdf/pdf.dart';

import '../../../report/data/services/report_generator.dart';

// Inside your inspection detail widget, add this method:
Future<void> _generateReport() async {
  // Get the ReportGenerator from DI
  final reportGenerator = getIt<ReportGenerator>();

  // Generate the PDF bytes from the current inspection data
  final pdfBytes = await reportGenerator.generateReport(
    inspection: inspection,
    items: items,
    photos: photos,
    signature: signature,
    inspectorName: currentUserName,
  );

  // Open the platform print/share preview dialog
  await Printing.layoutPdf(
    onLayout: (PdfPageFormat format) async => pdfBytes,
  );
}
  • Add the button widget in the build method below the signature thumbnail section:
// Generate Report button in the actions section
ElevatedButton.icon(
  onPressed: _generateReport,
  icon: const Icon(Icons.picture_as_pdf),
  label: const Text('Generate Report'),
  style: ElevatedButton.styleFrom(
    backgroundColor: Theme.of(context).colorScheme.primary,
    foregroundColor: Theme.of(context).colorScheme.onPrimary,
  ),
),

What does Printing.layoutPdf do?

Printing.layoutPdf opens the platform's native print dialog. On Android, this shows a preview of the PDF with options to print, save as PDF, or share to other apps. The onLayout callback receives the page format and returns the PDF bytes.

The inspector can email the report, save it to device storage, or send it directly to a Bluetooth printer from this single dialog.

  • Save inspection_detail_page.dart.
  • Run the app on your emulator. Open any inspection with at least one checklist item.
  • Tap Generate Report. You should see Android's print preview dialog showing your formatted PDF with the header, findings table, and any photos you have attached.

Print preview showing a blank page?

Make sure the inspection you open has items added. An inspection with zero checklist items produces an empty findings table. Also verify that ReportGenerator is registered in your DI container. Check the generated injection.config.dart file.

help me debug why my PDF report is showing up blank in the print preview

Add batch report generation to the inspection list

Sometimes inspectors need to combine multiple inspections into a single document for a supervisor handoff. Multi-select mode on the list page lets them pick several inspections and generate one combined PDF.

  • Open lib/features/inspection/presentation/pages/inspection_list_page.dart.
  • Add state variables and a method for multi-select mode with a 'Generate Combined PDF' action. Copy the following code:
import 'package:printing/printing.dart';
import 'package:pdf/pdf.dart';

import '../../../report/data/services/report_generator.dart';

// Add these state variables inside your StatefulWidget state class
bool _isMultiSelectMode = false;
final Set<String> _selectedInspectionIds = {};

// Toggle multi-select on long press
void _onLongPress(String inspectionId) {
  setState(() {
    _isMultiSelectMode = true;
    _selectedInspectionIds.add(inspectionId);
  });
}

// Toggle individual selection
void _onTapInMultiSelect(String inspectionId) {
  setState(() {
    if (_selectedInspectionIds.contains(inspectionId)) {
      _selectedInspectionIds.remove(inspectionId);
      if (_selectedInspectionIds.isEmpty) {
        _isMultiSelectMode = false;
      }
    } else {
      _selectedInspectionIds.add(inspectionId);
    }
  });
}

// Cancel multi-select
void _cancelMultiSelect() {
  setState(() {
    _isMultiSelectMode = false;
    _selectedInspectionIds.clear();
  });
}

// Generate combined PDF for all selected inspections
Future<void> _generateBatchReport() async {
  final reportGenerator = getIt<ReportGenerator>();

  // Gather data for all selected inspections
  final inspections = <InspectionEntity>[];
  final itemsMap = <String, List<InspectionItemEntity>>{};
  final photosMap = <String, List<InspectionPhotoEntity>>{};
  final signaturesMap = <String, SignatureEntity?>{};

  for (final id in _selectedInspectionIds) {
    // Load each inspection's full data from the repository
    final inspection = await getIt<InspectionRepository>().getInspectionById(id);
    inspection.fold(
      (failure) => null,
      (data) {
        inspections.add(data.inspection);
        itemsMap[id] = data.items;
        photosMap[id] = data.photos;
        signaturesMap[id] = data.signature;
      },
    );
  }

  // Generate combined PDF
  final pdfBytes = await reportGenerator.generateBatchReport(
    inspections: inspections,
    itemsMap: itemsMap,
    photosMap: photosMap,
    signaturesMap: signaturesMap,
  );

  // Open print/share dialog
  await Printing.layoutPdf(
    onLayout: (PdfPageFormat format) async => pdfBytes,
  );

  // Exit multi-select mode after generation
  _cancelMultiSelect();
}
  • Update the app bar to show multi-select actions when in that mode:
// In the build method, conditionally show the multi-select app bar
appBar: _isMultiSelectMode
    ? AppBar(
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: _cancelMultiSelect,
        ),
        title: Text('${_selectedInspectionIds.length} selected'),
        actions: [
          // Generate Combined PDF action button
          IconButton(
            icon: const Icon(Icons.picture_as_pdf),
            tooltip: 'Generate Combined PDF',
            onPressed: _selectedInspectionIds.isNotEmpty
                ? _generateBatchReport
                : null,
          ),
        ],
      )
    : AppBar(
        title: const Text('Inspections'),
        // Existing sync status indicator stays here
      ),

How does multi-select work?

Long-pressing an inspection card activates multi-select mode. The app bar changes to show a count and a PDF icon. Tapping additional cards toggles their selection. When the inspector taps the PDF icon, the app loads all selected inspections' data and feeds it to generateBatchReport which creates one document with each inspection on its own page section.

  • Save inspection_list_page.dart.
  • Hot restart the app. Long-press an inspection card on the list.
  • You should see the app bar change to show a selection count with a close button and a PDF icon. Tap a few more cards, then tap the PDF icon. The print preview opens with a combined report.

Multi-select not activating on long press?

Make sure the GestureDetector or InkWell wrapping your list card includes an onLongPress callback. If you are using a Dismissible for swipe-to-delete, it can consume the gesture. Disable Dismissible when _isMultiSelectMode is true.

help me wire up long-press multi-select in my inspection list page

✔️ 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 report_generator.dart file:

import 'dart:io';
import 'dart:typed_data';

import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:injectable/injectable.dart';
import 'package:intl/intl.dart';

import '../../../inspection/domain/entities/inspection_entity.dart';
import '../../../inspection/domain/entities/inspection_item_entity.dart';
import '../../../inspection/domain/entities/inspection_photo_entity.dart';
import '../../../signature/domain/entities/signature_entity.dart';

@lazySingleton
class ReportGenerator {
  /// Generates a PDF report for a single inspection
  Future<Uint8List> generateReport({
    required InspectionEntity inspection,
    required List<InspectionItemEntity> items,
    required List<InspectionPhotoEntity> photos,
    SignatureEntity? signature,
    String? inspectorName,
  }) async {
    final pdf = pw.Document();

    // Build the multi-page A4 document
    pdf.addPage(
      pw.MultiPage(
        pageFormat: PdfPageFormat.a4,
        header: (context) => _buildHeader(inspection, inspectorName),
        footer: (context) => _buildFooter(context),
        build: (context) => [
          _buildLocationSection(inspection),
          pw.SizedBox(height: 20),
          _buildFindingsTable(items),
          pw.SizedBox(height: 20),
          ..._buildPhotosSection(photos),
          if (signature != null) ..._buildSignatureSection(signature),
          pw.SizedBox(height: 20),
          _buildStatusSummary(inspection, items),
        ],
      ),
    );

    return pdf.save();
  }

  /// Header section: title, date, inspector name, category
  pw.Widget _buildHeader(
    InspectionEntity inspection,
    String? inspectorName,
  ) {
    final dateFormat = DateFormat('MMMM d, yyyy - HH:mm');
    return pw.Container(
      padding: const pw.EdgeInsets.only(bottom: 12),
      decoration: const pw.BoxDecoration(
        border: pw.Border(
          bottom: pw.BorderSide(width: 2, color: PdfColors.indigo),
        ),
      ),
      child: pw.Column(
        crossAxisAlignment: pw.CrossAxisAlignment.start,
        children: [
          pw.Text(
            inspection.title,
            style: pw.TextStyle(
              fontSize: 22,
              fontWeight: pw.FontWeight.bold,
              color: PdfColors.indigo,
            ),
          ),
          pw.SizedBox(height: 4),
          pw.Row(
            mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
            children: [
              pw.Text(
                'Inspector: ${inspectorName ?? "Unknown"}',
                style: const pw.TextStyle(fontSize: 11),
              ),
              pw.Text(
                dateFormat.format(inspection.createdAt),
                style: const pw.TextStyle(fontSize: 11),
              ),
            ],
          ),
          pw.SizedBox(height: 2),
          pw.Text(
            'Category: ${inspection.category}',
            style: pw.TextStyle(
              fontSize: 11,
              fontWeight: pw.FontWeight.bold,
            ),
          ),
        ],
      ),
    );
  }

  /// Footer with page numbers
  pw.Widget _buildFooter(pw.Context context) {
    return pw.Container(
      alignment: pw.Alignment.centerRight,
      margin: const pw.EdgeInsets.only(top: 8),
      child: pw.Text(
        'Page ${context.pageNumber} of ${context.pagesCount}',
        style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600),
      ),
    );
  }

  /// Location section with clickable Google Maps link
  pw.Widget _buildLocationSection(InspectionEntity inspection) {
    final hasCoordinates =
        inspection.latitude != null && inspection.longitude != null;
    final mapsUrl = hasCoordinates
        ? 'https://www.google.com/maps?q=${inspection.latitude},${inspection.longitude}'
        : null;

    return pw.Container(
      padding: const pw.EdgeInsets.all(10),
      decoration: pw.BoxDecoration(
        color: PdfColors.grey100,
        borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
      ),
      child: pw.Column(
        crossAxisAlignment: pw.CrossAxisAlignment.start,
        children: [
          pw.Text(
            'Location',
            style: pw.TextStyle(
              fontSize: 14,
              fontWeight: pw.FontWeight.bold,
            ),
          ),
          pw.SizedBox(height: 4),
          pw.Text(
            inspection.locationName ?? 'No location specified',
            style: const pw.TextStyle(fontSize: 11),
          ),
          if (hasCoordinates) ...[            pw.SizedBox(height: 4),
            pw.Text(
              'Coordinates: ${inspection.latitude}, ${inspection.longitude}',
              style: const pw.TextStyle(fontSize: 10),
            ),
            pw.SizedBox(height: 2),
            pw.UrlLink(
              destination: mapsUrl!,
              child: pw.Text(
                'View on Google Maps',
                style: pw.TextStyle(
                  fontSize: 10,
                  color: PdfColors.blue,
                  decoration: pw.TextDecoration.underline,
                ),
              ),
            ),
          ],
        ],
      ),
    );
  }

  /// Findings table with severity color coding
  pw.Widget _buildFindingsTable(List<InspectionItemEntity> items) {
    return pw.Column(
      crossAxisAlignment: pw.CrossAxisAlignment.start,
      children: [
        pw.Text(
          'Findings',
          style: pw.TextStyle(
            fontSize: 14,
            fontWeight: pw.FontWeight.bold,
          ),
        ),
        pw.SizedBox(height: 8),
        pw.Table(
          border: pw.TableBorder.all(color: PdfColors.grey300),
          columnWidths: {
            0: const pw.FlexColumnWidth(3),
            1: const pw.FlexColumnWidth(1),
            2: const pw.FlexColumnWidth(1),
            3: const pw.FlexColumnWidth(2),
          },
          children: [
            // Table header row
            pw.TableRow(
              decoration: const pw.BoxDecoration(color: PdfColors.grey200),
              children: [
                _tableCell('Description', isHeader: true),
                _tableCell('Severity', isHeader: true),
                _tableCell('Status', isHeader: true),
                _tableCell('Notes', isHeader: true),
              ],
            ),
            // Data rows with severity color coding
            ...items.map((item) => pw.TableRow(
              children: [
                _tableCell(item.description),
                _severityCell(item.severity),
                _tableCell(item.isCompleted ? 'Done' : 'Pending'),
                _tableCell(item.notes ?? '-'),
              ],
            )),
          ],
        ),
      ],
    );
  }

  /// Helper for standard table cells
  pw.Widget _tableCell(String text, {bool isHeader = false}) {
    return pw.Padding(
      padding: const pw.EdgeInsets.all(6),
      child: pw.Text(
        text,
        style: pw.TextStyle(
          fontSize: 9,
          fontWeight: isHeader ? pw.FontWeight.bold : pw.FontWeight.normal,
        ),
      ),
    );
  }

  /// Severity cell with color coding
  pw.Widget _severityCell(String severity) {
    final color = _severityColor(severity);
    return pw.Container(
      padding: const pw.EdgeInsets.all(6),
      color: color.withValues(alpha: 0.2),
      child: pw.Text(
        severity.toUpperCase(),
        style: pw.TextStyle(
          fontSize: 9,
          fontWeight: pw.FontWeight.bold,
          color: color,
        ),
      ),
    );
  }

  /// Maps severity strings to PDF colors
  PdfColor _severityColor(String severity) {
    switch (severity.toLowerCase()) {
      case 'low':
        return PdfColors.green;
      case 'medium':
        return PdfColors.yellow800;
      case 'high':
        return PdfColors.orange;
      case 'critical':
        return PdfColors.red;
      default:
        return PdfColors.grey;
    }
  }

  /// Photos section: embeds images from local file paths
  List<pw.Widget> _buildPhotosSection(List<InspectionPhotoEntity> photos) {
    if (photos.isEmpty) return [];

    return [
      pw.Text(
        'Photos',
        style: pw.TextStyle(
          fontSize: 14,
          fontWeight: pw.FontWeight.bold,
        ),
      ),
      pw.SizedBox(height: 8),
      pw.Wrap(
        spacing: 8,
        runSpacing: 8,
        children: photos.map((photo) {
          final file = File(photo.storagePath);
          if (!file.existsSync()) {
            return pw.Container(
              width: 150,
              height: 100,
              color: PdfColors.grey300,
              child: pw.Center(
                child: pw.Text('Photo unavailable', style: const pw.TextStyle(fontSize: 8)),
              ),
            );
          }
          final image = pw.MemoryImage(file.readAsBytesSync());
          return pw.Container(
            width: 150,
            height: 100,
            child: pw.Column(
              children: [
                pw.Expanded(
                  child: pw.Image(image, fit: pw.BoxFit.cover),
                ),
                if (photo.caption != null)
                  pw.Text(
                    photo.caption!,
                    style: const pw.TextStyle(fontSize: 8),
                  ),
              ],
            ),
          );
        }).toList(),
      ),
      pw.SizedBox(height: 20),
    ];
  }

  /// Signature section: embeds the saved signature PNG
  List<pw.Widget> _buildSignatureSection(SignatureEntity signature) {
    final file = File(signature.storagePath);
    if (!file.existsSync()) return [];

    final image = pw.MemoryImage(file.readAsBytesSync());
    return [
      pw.SizedBox(height: 20),
      pw.Text(
        'Sign-Off',
        style: pw.TextStyle(
          fontSize: 14,
          fontWeight: pw.FontWeight.bold,
        ),
      ),
      pw.SizedBox(height: 8),
      pw.Row(
        children: [
          pw.Container(
            width: 200,
            height: 80,
            decoration: pw.BoxDecoration(
              border: pw.Border.all(color: PdfColors.grey400),
            ),
            child: pw.Image(image, fit: pw.BoxFit.contain),
          ),
          pw.SizedBox(width: 12),
          pw.Text(
            'Signed by: ${signature.signerName}',
            style: const pw.TextStyle(fontSize: 11),
          ),
        ],
      ),
    ];
  }

  /// Status summary at the bottom
  pw.Widget _buildStatusSummary(
    InspectionEntity inspection,
    List<InspectionItemEntity> items,
  ) {
    final completed = items.where((i) => i.isCompleted).length;
    final total = items.length;

    return pw.Container(
      padding: const pw.EdgeInsets.all(10),
      decoration: pw.BoxDecoration(
        color: PdfColors.indigo50,
        borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
      ),
      child: pw.Row(
        mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
        children: [
          pw.Text(
            'Status: ${inspection.status.toUpperCase()}',
            style: pw.TextStyle(
              fontSize: 12,
              fontWeight: pw.FontWeight.bold,
            ),
          ),
          pw.Text(
            'Items completed: $completed / $total',
            style: const pw.TextStyle(fontSize: 11),
          ),
        ],
      ),
    );
  }

  /// Generates a combined PDF for multiple inspections (batch mode)
  Future<Uint8List> generateBatchReport({
    required List<InspectionEntity> inspections,
    required Map<String, List<InspectionItemEntity>> itemsMap,
    required Map<String, List<InspectionPhotoEntity>> photosMap,
    required Map<String, SignatureEntity?> signaturesMap,
    String? inspectorName,
  }) async {
    final pdf = pw.Document();

    // Each inspection gets its own multi-page section
    for (final inspection in inspections) {
      final items = itemsMap[inspection.id] ?? [];
      final photos = photosMap[inspection.id] ?? [];
      final signature = signaturesMap[inspection.id];

      pdf.addPage(
        pw.MultiPage(
          pageFormat: PdfPageFormat.a4,
          header: (context) => _buildHeader(inspection, inspectorName),
          footer: (context) => _buildFooter(context),
          build: (context) => [
            _buildLocationSection(inspection),
            pw.SizedBox(height: 20),
            _buildFindingsTable(items),
            pw.SizedBox(height: 20),
            ..._buildPhotosSection(photos),
            if (signature != null) ..._buildSignatureSection(signature),
            pw.SizedBox(height: 20),
            _buildStatusSummary(inspection, items),
          ],
        ),
      );
    }

    return pdf.save();
  }
}

Your app can now produce professional PDF reports on demand. Next up, you will write a comprehensive test suite to verify all the logic you have built so far.

Write Tests

Your app has all its features working. But how do you know they still work after you refactor something next week?

Automated tests catch regressions before they reach users. In this step, you will write unit tests for your use cases, BLoC tests for your state management, and repository integration tests to verify your data layer.

In this step, get ready to:
  • Write unit tests for use cases with mocktail.
  • Write BLoC tests for AuthBloc, InspectionListBloc, InspectionFormBloc, and SyncBloc.
  • Write repository integration tests with mocked DAOs and Supabase client.
Write unit tests for use cases

Use cases are the simplest units to test because they have a single responsibility: call the repository and return an Either result. You mock the repository, stub its methods, and verify the correct path is returned.

  • Create the test file for CreateInspection by running:
mkdir -p test/features/inspection/domain/usecases
touch test/features/inspection/domain/usecases/create_inspection_test.dart
  • Add the following test code to create_inspection_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';
import 'package:field_inspector/features/inspection/domain/repositories/inspection_repository.dart';
import 'package:field_inspector/features/inspection/domain/usecases/create_inspection.dart';
import 'package:field_inspector/features/inspection/domain/entities/inspection_entity.dart';
import 'package:field_inspector/core/error/failures.dart';

// Mock the repository interface
class MockInspectionRepository extends Mock implements InspectionRepository {}

void main() {
  late CreateInspection useCase;
  late MockInspectionRepository mockRepository;

  setUp(() {
    mockRepository = MockInspectionRepository();
    useCase = CreateInspection(mockRepository);
  });

  final testInspection = InspectionEntity(
    id: 'test-uuid-123',
    userId: 'user-uuid-456',
    title: 'Test Inspection',
    locationName: 'Building A',
    latitude: 37.7749,
    longitude: -122.4194,
    category: 'Structural',
    status: 'draft',
    createdAt: DateTime.now(),
    updatedAt: DateTime.now(),
  );

  group('CreateInspection', () {
    test('returns Right(InspectionEntity) on success', () async {
      // Arrange: stub the repository to return success
      when(() => mockRepository.createInspection(any()))
          .thenAnswer((_) async => Right(testInspection));

      // Act: execute the use case
      final result = await useCase(testInspection);

      // Assert: verify the result is Right and repo was called
      expect(result, Right(testInspection));
      verify(() => mockRepository.createInspection(any())).called(1);
    });

    test('returns Left(CacheFailure) on failure', () async {
      // Arrange: stub the repository to return failure
      when(() => mockRepository.createInspection(any()))
          .thenAnswer((_) async => Left(CacheFailure('Write failed')));

      // Act
      final result = await useCase(testInspection);

      // Assert: verify the result is Left with CacheFailure
      expect(result.isLeft(), true);
      result.fold(
        (failure) => expect(failure, isA<CacheFailure>()),
        (_) => fail('Expected Left'),
      );
    });
  });
}

What does this test do?

MockInspectionRepository extends Mock from mocktail. This lets you control exactly what the repository returns without touching a real database.

The when(() => ...).thenAnswer(...) pattern stubs the mock so it returns your chosen value. You then call the use case and verify the Either result is Right (success) or Left (failure).

  • Follow the same pattern to create tests for the remaining use cases. Create these files:
touch test/features/inspection/domain/usecases/get_inspections_test.dart
touch test/features/inspection/domain/usecases/update_inspection_test.dart
touch test/features/inspection/domain/usecases/delete_inspection_test.dart
mkdir -p test/features/signature/domain/usecases
touch test/features/signature/domain/usecases/capture_signature_test.dart

Each test file follows the identical structure: mock the repository, test the success path with Right, test the failure path with Left.

  • In get_inspections_test.dart, stub watchAllInspections() to return a stream wrapped in Right, and test the failure path returns Left(ServerFailure(...)).
  • In update_inspection_test.dart, stub updateInspection() and verify both Right and Left(CacheFailure(...)) paths.
  • In delete_inspection_test.dart, stub deleteInspection() and test success returns Right(unit) and failure returns Left(CacheFailure(...)).
  • In capture_signature_test.dart, mock the SignatureRepository and verify success returns Right(SignatureEntity) and failure returns Left(StorageFailure(...)).
  • Run all use case tests by running:
flutter test test/features/inspection/domain/usecases/
flutter test test/features/signature/domain/usecases/

You should see all tests passing with green checkmarks.

Tests failing with type errors?

If you see errors about non-nullable return types, make sure you call registerFallbackValue() in a setUpAll block for any custom types you pass to any(). Mocktail needs fallback values for custom objects used with argument matchers.

help me fix type errors in my mocktail tests

Write BLoC tests with bloc_test

BLoC tests verify that adding an event produces the correct sequence of states. The blocTest helper from the bloc_test package handles building the BLoC, dispatching events, and asserting emitted states in one clean declaration.

  • Create the AuthBloc test file by running:
mkdir -p test/features/auth/presentation/bloc
touch test/features/auth/presentation/bloc/auth_bloc_test.dart
  • Add the following test code to auth_bloc_test.dart:
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:fpdart/fpdart.dart';
import 'package:field_inspector/features/auth/presentation/bloc/auth_bloc.dart';
import 'package:field_inspector/features/auth/domain/repositories/auth_repository.dart';
import 'package:field_inspector/core/error/failures.dart';

// Mock the auth repository
class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  late AuthBloc authBloc;
  late MockAuthRepository mockAuthRepository;

  setUp(() {
    mockAuthRepository = MockAuthRepository();
    authBloc = AuthBloc(authRepository: mockAuthRepository);
  });

  tearDown(() {
    authBloc.close();
  });

  group('AuthBloc', () {
    test('initial state is AuthInitial', () {
      expect(authBloc.state, isA<AuthInitial>());
    });

    blocTest<AuthBloc, AuthState>(
      'emits [AuthLoading, Authenticated] when LoginRequested succeeds',
      build: () {
        when(() => mockAuthRepository.login(
          email: any(named: 'email'),
          password: any(named: 'password'),
        )).thenAnswer((_) async => const Right(unit));
        return authBloc;
      },
      act: (bloc) => bloc.add(const LoginRequested(
        email: 'test@example.com',
        password: 'password123',
      )),
      expect: () => [
        isA<AuthLoading>(),
        isA<Authenticated>(),
      ],
    );

    blocTest<AuthBloc, AuthState>(
      'emits [AuthLoading, AuthError] when LoginRequested fails',
      build: () {
        when(() => mockAuthRepository.login(
          email: any(named: 'email'),
          password: any(named: 'password'),
        )).thenAnswer((_) async => Left(AuthFailure('Invalid credentials')));
        return authBloc;
      },
      act: (bloc) => bloc.add(const LoginRequested(
        email: 'test@example.com',
        password: 'wrong',
      )),
      expect: () => [
        isA<AuthLoading>(),
        isA<AuthError>(),
      ],
    );

    blocTest<AuthBloc, AuthState>(
      'emits [Unauthenticated] when LogoutRequested is added',
      build: () {
        when(() => mockAuthRepository.logout())
            .thenAnswer((_) async => const Right(unit));
        return authBloc;
      },
      act: (bloc) => bloc.add(const LogoutRequested()),
      expect: () => [
        isA<Unauthenticated>(),
      ],
    );
  });
}

What does blocTest do?

The blocTest function from bloc_test creates a complete test case. It builds the BLoC, dispatches events via act, then asserts the exact sequence of emitted states via expect. It also closes the BLoC stream automatically to ensure no extra states leak out.

Using isA<AuthLoading>() matchers works well with Freezed states because Freezed generates proper equality overrides.

  • Create test files for the remaining BLoCs by running:
mkdir -p test/features/inspection/presentation/bloc
touch test/features/inspection/presentation/bloc/inspection_list_bloc_test.dart
touch test/features/inspection/presentation/bloc/inspection_form_bloc_test.dart
mkdir -p test/features/sync/presentation/bloc
touch test/features/sync/presentation/bloc/sync_bloc_test.dart
  • In inspection_list_bloc_test.dart, test these scenarios:
  • In inspection_form_bloc_test.dart, test these scenarios:
  • In sync_bloc_test.dart, test these scenarios:
  • Run the BLoC tests by running:
flutter test test/features/auth/presentation/bloc/
flutter test test/features/inspection/presentation/bloc/
flutter test test/features/sync/presentation/bloc/

All tests should pass. Each blocTest declaration is self-contained and readable.

BLoC tests timing out?

If your BLoC uses streams or async operations internally, add a wait parameter to blocTest to give async operations time to complete. For example: wait: const Duration(milliseconds: 300).

help me fix timing issues in my BLoC tests

Write repository integration tests

Repository tests sit between unit tests and end-to-end tests. You mock the DAOs and Supabase client, then verify that the repository correctly maps data, enqueues sync entries on writes, and processes the sync queue.

  • Create the repository test files by running:
mkdir -p test/features/inspection/data/repositories
touch test/features/inspection/data/repositories/inspection_repository_impl_test.dart
mkdir -p test/features/sync/data/services
touch test/features/sync/data/services/sync_engine_test.dart
  • Add the following test code to inspection_repository_impl_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';
import 'package:field_inspector/features/inspection/data/repositories/inspection_repository_impl.dart';
import 'package:field_inspector/features/inspection/domain/entities/inspection_entity.dart';
import 'package:field_inspector/core/database/daos/inspection_dao.dart';
import 'package:field_inspector/core/database/daos/sync_queue_dao.dart';
import 'package:field_inspector/core/error/failures.dart';

// Mock the DAOs
class MockInspectionDao extends Mock implements InspectionDao {}
class MockSyncQueueDao extends Mock implements SyncQueueDao {}

void main() {
  late InspectionRepositoryImpl repository;
  late MockInspectionDao mockInspectionDao;
  late MockSyncQueueDao mockSyncQueueDao;

  setUp(() {
    mockInspectionDao = MockInspectionDao();
    mockSyncQueueDao = MockSyncQueueDao();
    repository = InspectionRepositoryImpl(
      inspectionDao: mockInspectionDao,
      syncQueueDao: mockSyncQueueDao,
    );
  });

  group('InspectionRepositoryImpl', () {
    group('createInspection', () {
      test('inserts into DAO and enqueues sync entry on success', () async {
        // Arrange
        final entity = InspectionEntity(
          id: 'test-id',
          userId: 'user-id',
          title: 'Site Check',
          locationName: 'Floor 3',
          latitude: 40.7128,
          longitude: -74.0060,
          category: 'Electrical',
          status: 'draft',
          createdAt: DateTime.now(),
          updatedAt: DateTime.now(),
        );

        when(() => mockInspectionDao.insertInspection(any()))
            .thenAnswer((_) async => 1);
        when(() => mockSyncQueueDao.enqueue(any()))
            .thenAnswer((_) async => 1);

        // Act
        final result = await repository.createInspection(entity);

        // Assert: verify Right is returned
        expect(result.isRight(), true);

        // Assert: verify sync queue was populated
        verify(() => mockSyncQueueDao.enqueue(any())).called(1);
      });

      test('returns Left(CacheFailure) when DAO throws', () async {
        // Arrange
        final entity = InspectionEntity(
          id: 'test-id',
          userId: 'user-id',
          title: 'Site Check',
          locationName: 'Floor 3',
          latitude: 40.7128,
          longitude: -74.0060,
          category: 'Electrical',
          status: 'draft',
          createdAt: DateTime.now(),
          updatedAt: DateTime.now(),
        );

        when(() => mockInspectionDao.insertInspection(any()))
            .thenThrow(Exception('DB write failed'));

        // Act
        final result = await repository.createInspection(entity);

        // Assert
        expect(result.isLeft(), true);
        result.fold(
          (failure) => expect(failure, isA<CacheFailure>()),
          (_) => fail('Expected Left'),
        );

        // Sync queue should NOT be called on failure
        verifyNever(() => mockSyncQueueDao.enqueue(any()));
      });
    });
  });
}

Why test the sync queue interaction?

The sync queue is the backbone of your offline-first architecture. Every write to the local database must also enqueue a sync entry. If this breaks, data silently never reaches the server. Testing this interaction explicitly ensures the contract holds.

The verifyNever assertion in the failure test confirms that a failed write does not pollute the sync queue with invalid entries.

  • In sync_engine_test.dart, test the sync engine's queue processing logic:
  • Run the full test suite by running:
flutter test

You should see all test files passing. The output shows each test group and individual test result.

Seeing unregistered fallback value errors?

When you use any() with custom types in mocktail, you must register a fallback value first. Add a setUpAll block at the top of your main() that registers fake instances of your custom types.

help me fix registerFallbackValue errors in my repository tests

Your test suite now covers the critical paths through your application. Next up, you will add Sentry crash reporting and structured logging to catch issues in production that tests cannot simulate.

Add Sentry Crash Reporting and Structured Logging

Your app now has a full test suite validating all features. But tests only catch problems you anticipate.

Production apps crash in ways you never predicted. A user loses connectivity mid-sync, a photo file corrupts, or a GPS timeout cascades into an unhandled exception. Without crash reporting, these failures are invisible. In this step, you will wire up Sentry to capture every unhandled error, track navigation breadcrumbs, and log key operations so you can diagnose production issues from your dashboard.

In this step, get ready to:
  • Configure SentryFlutter.init with full error handling in main().
  • Create a structured AppLogger class that forwards errors to Sentry.
  • Add performance traces and user context tagging to sync operations.
Initialize SentryFlutter.init with error handling

Sentry needs to initialize before anything else in your app runs. This ensures that any crash during startup is captured. The appRunner callback pattern wraps your entire app lifecycle inside Sentry's error boundary.

  • Open lib/main.dart and replace its contents with the following initialization code:
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'app.dart';
import 'core/di/injection.dart';

Future<void> main() async {
  // Sentry must initialize before all other services
  await SentryFlutter.init(
    (options) {
      options.dsn = 'your-sentry-dsn-here';
      options.environment = 'development';
      options.release = 'field_inspector@1.0.0+1';
      // Capture 100% of transactions for tracing during development
      options.tracesSampleRate = 1.0;
    },
    appRunner: () async {
      WidgetsFlutterBinding.ensureInitialized();

      // Initialize Supabase
      await Supabase.initialize(
        url: 'your-supabase-url-here',
        anonKey: 'your-supabase-anon-key-here',
      );

      // Configure dependency injection
      configureDependencies();

      // Run app inside a guarded zone to catch async errors
      runZonedGuarded(
        () => runApp(const FieldInspectorApp()),
        (error, stackTrace) {
          // Forward uncaught async errors to Sentry
          Sentry.captureException(error, stackTrace: stackTrace);
        },
      );
    },
  );
}

What does this code do?

SentryFlutter.init sets up the SDK with your project's DSN, environment tag, and release version. Sentry uses these to group errors by deployment.

The appRunner callback ensures Sentry wraps your entire app. Inside it, runZonedGuarded catches any async exceptions that escape the Flutter framework and forwards them to Sentry via Sentry.captureException.

Setting tracesSampleRate to 1.0 captures all transactions during development. You would lower this in production to reduce volume.

  • Now open lib/app.dart and add SentryNavigatorObserver to your MaterialApp's navigatorObservers list:
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Field Inspector',
      // Track navigation events as Sentry breadcrumbs
      navigatorObservers: [
        SentryNavigatorObserver(),
      ],
      // ... rest of your MaterialApp configuration
    );
  }
}

What does SentryNavigatorObserver do?

Every time the user navigates to a new screen, Sentry records a breadcrumb. When a crash happens, you can see the exact navigation path that led to it. This is invaluable for reproducing user-reported bugs.

  • Save both lib/main.dart and lib/app.dart.
  • Run the app on your emulator. You should see no errors in the console, and the app starts normally.

App won't start or shows a Sentry error?

Make sure your DSN string is correct. A malformed DSN causes Sentry to fail silently, but the runZonedGuarded wrapper still keeps your app running.

Check that sentry_flutter is listed in your pubspec.yaml dependencies and you have run flutter pub get.

help me debug my Sentry initialization in Flutter

Create the AppLogger class

A centralized logger gives you consistent log formatting across all features. By routing errors through a single class, you guarantee that every error-level log also reaches Sentry without requiring individual Sentry.captureException calls scattered throughout your codebase.

  • Create the file lib/core/logging/app_logger.dart and add the following code:
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

/// Centralized logger that forwards errors to Sentry
/// and provides structured logging for key operations.
class AppLogger {
  // Log a debug message (development only)
  static void debug(String message, {String? tag}) {
    if (kDebugMode) {
      developer.log(message, name: tag ?? 'DEBUG');
    }
  }

  // Log an informational event
  static void info(String message, {String? tag}) {
    developer.log(message, name: tag ?? 'INFO');
    // Add Sentry breadcrumb for traceability
    Sentry.addBreadcrumb(
      Breadcrumb(
        message: message,
        category: tag ?? 'info',
        level: SentryLevel.info,
      ),
    );
  }

  // Log a warning
  static void warning(String message, {String? tag}) {
    developer.log(message, name: tag ?? 'WARNING', level: 900);
    Sentry.addBreadcrumb(
      Breadcrumb(
        message: message,
        category: tag ?? 'warning',
        level: SentryLevel.warning,
      ),
    );
  }

  // Log an error and forward to Sentry
  static void error(
    String message, {
    Object? exception,
    StackTrace? stackTrace,
    String? tag,
  }) {
    developer.log(
      message,
      name: tag ?? 'ERROR',
      level: 1000,
      error: exception,
      stackTrace: stackTrace,
    );

    // Automatically capture in Sentry
    if (exception != null) {
      Sentry.captureException(
        exception,
        stackTrace: stackTrace,
      );
    } else {
      Sentry.captureMessage(message, level: SentryLevel.error);
    }
  }

  // Start a Sentry performance transaction
  static Future<T> traceAsync<T>(
    String name,
    String operation,
    Future<T> Function(ISentrySpan transaction) work,
  ) async {
    final transaction = Sentry.startTransaction(
      name,
      operation,
      bindToScope: true,
    );

    try {
      final result = await work(transaction);
      transaction.status = const SpanStatus.ok();
      return result;
    } catch (e) {
      transaction.throwable = e;
      transaction.status = const SpanStatus.internalError();
      rethrow;
    } finally {
      await transaction.finish();
    }
  }
}

What does this code do?

  • debug() logs only in debug mode. It never reaches Sentry.
  • info() and warning() log locally and add Sentry breadcrumbs. Breadcrumbs appear as context when a later crash is reported.
  • error() logs locally and forwards the exception directly to Sentry via Sentry.captureException. If no exception object exists, it sends a message-level error instead.
  • traceAsync() wraps any async operation in a Sentry performance transaction. It sets the status to OK on success or internal error on failure, then finishes the transaction so Sentry records the duration.
  • Save lib/core/logging/app_logger.dart.

Seeing import errors?

Make sure you have sentry_flutter imported. The ISentrySpan type, Breadcrumb, and SpanStatus all come from the sentry_flutter package.

help me fix import errors in my AppLogger class

Add performance traces and user context to the sync engine and auth

Raw crash reports are hard to debug without context. By tagging errors with the user's ID, inspection count, and sync queue size, you can immediately see which user was affected and what the app's state was when things went wrong.

  • Open lib/features/auth/presentation/bloc/auth_bloc.dart. Add logging for auth events and Sentry user context by updating the event handlers:
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../../../core/logging/app_logger.dart';

// Inside your AuthBloc event handlers:

// On successful login:
AppLogger.info('User logged in', tag: 'auth');
Sentry.configureScope(
  (scope) => scope.setUser(
    SentryUser(id: user.id),
  ),
);

// On logout:
AppLogger.info('User logged out', tag: 'auth');
Sentry.configureScope(
  (scope) => scope.setUser(null),
);

// On auth error:
AppLogger.error(
  'Authentication failed',
  exception: failure,
  tag: 'auth',
);

Why set the Sentry user on login?

Sentry.configureScope with setUser attaches the user's ID to every subsequent error report. When you receive a crash in your Sentry dashboard, you immediately know which user experienced it. Clearing the user on logout ensures you never misattribute a crash to the wrong person.

  • Now open lib/features/sync/data/services/sync_engine.dart. Wrap the sync operation in a Sentry performance transaction and add context tags:
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../../../core/logging/app_logger.dart';

// Inside your SyncEngine's processQueue method:

Future<void> processQueue() async {
  AppLogger.info('Sync started', tag: 'sync');

  await AppLogger.traceAsync(
    'sync.processQueue',
    'task',
    (transaction) async {
      // Tag the transaction with debugging context
      transaction.setData('userId', currentUserId);
      transaction.setData('inspectionCount', await getInspectionCount());
      transaction.setData('syncQueueSize', await getSyncQueueSize());

      // Also tag the Sentry scope for any errors during sync
      Sentry.configureScope((scope) {
        scope.setTag('sync.queue_size', (await getSyncQueueSize()).toString());
      });

      // Process each queued item
      final entries = await syncQueueDao.getPendingEntries();
      for (final entry in entries) {
        final span = transaction.startChild(
          'sync.item',
          description: '${entry.tableName}:${entry.action}',
        );

        try {
          await processEntry(entry);
          span.status = const SpanStatus.ok();
        } catch (e) {
          span.throwable = e;
          span.status = const SpanStatus.internalError();
          AppLogger.error(
            'Sync failed for ${entry.tableName}:${entry.recordId}',
            exception: e,
            tag: 'sync',
          );
        } finally {
          await span.finish();
        }
      }

      AppLogger.info(
        'Sync completed: ${entries.length} items processed',
        tag: 'sync',
      );
    },
  );
}

What does this code do?

  • AppLogger.traceAsync creates a Sentry transaction named sync.processQueue. Sentry records its total duration.
  • transaction.setData attaches the user ID, inspection count, and queue size to the transaction. These appear in the Sentry trace explorer.
  • Each queue entry gets its own child span. If one item fails, its span carries the exception while the parent transaction continues processing.
  • AppLogger.error automatically forwards the exception to Sentry with full stack trace context.
  • Save both lib/features/auth/presentation/bloc/auth_bloc.dart and lib/features/sync/data/services/sync_engine.dart.
  • Run the app, log in, and trigger a sync. Check your Sentry dashboard at sentry.io. You should see the login breadcrumb and a sync.processQueue transaction with child spans.

Nothing showing up in Sentry?

Events can take a few seconds to appear. Refresh your Sentry project's Issues and Performance pages after 10-15 seconds.

Verify your DSN is correct and that your emulator has internet access. Sentry silently drops events if it cannot reach the server.

help me debug why my Sentry events are not appearing in the dashboard

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: lib/main.dart, lib/app.dart, and lib/core/logging/app_logger.dart.

ⓧ I'd like to double check the full code

Here is the full code for each file:

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'app.dart';
import 'core/di/injection.dart';

Future<void> main() async {
  // Sentry must initialize before all other services
  await SentryFlutter.init(
    (options) {
      options.dsn = 'your-sentry-dsn-here';
      options.environment = 'development';
      options.release = 'field_inspector@1.0.0+1';
      // Capture 100% of transactions for tracing during development
      options.tracesSampleRate = 1.0;
    },
    appRunner: () async {
      WidgetsFlutterBinding.ensureInitialized();

      // Initialize Supabase
      await Supabase.initialize(
        url: 'your-supabase-url-here',
        anonKey: 'your-supabase-anon-key-here',
      );

      // Configure dependency injection
      configureDependencies();

      // Run app inside a guarded zone to catch async errors
      runZonedGuarded(
        () => runApp(const FieldInspectorApp()),
        (error, stackTrace) {
          // Forward uncaught async errors to Sentry
          Sentry.captureException(error, stackTrace: stackTrace);
        },
      );
    },
  );
}
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Field Inspector',
      // Track navigation events as Sentry breadcrumbs
      navigatorObservers: [
        SentryNavigatorObserver(),
      ],
      // ... rest of your MaterialApp configuration
    );
  }
}
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

/// Centralized logger that forwards errors to Sentry
/// and provides structured logging for key operations.
class AppLogger {
  // Log a debug message (development only)
  static void debug(String message, {String? tag}) {
    if (kDebugMode) {
      developer.log(message, name: tag ?? 'DEBUG');
    }
  }

  // Log an informational event
  static void info(String message, {String? tag}) {
    developer.log(message, name: tag ?? 'INFO');
    // Add Sentry breadcrumb for traceability
    Sentry.addBreadcrumb(
      Breadcrumb(
        message: message,
        category: tag ?? 'info',
        level: SentryLevel.info,
      ),
    );
  }

  // Log a warning
  static void warning(String message, {String? tag}) {
    developer.log(message, name: tag ?? 'WARNING', level: 900);
    Sentry.addBreadcrumb(
      Breadcrumb(
        message: message,
        category: tag ?? 'warning',
        level: SentryLevel.warning,
      ),
    );
  }

  // Log an error and forward to Sentry
  static void error(
    String message, {
    Object? exception,
    StackTrace? stackTrace,
    String? tag,
  }) {
    developer.log(
      message,
      name: tag ?? 'ERROR',
      level: 1000,
      error: exception,
      stackTrace: stackTrace,
    );

    // Automatically capture in Sentry
    if (exception != null) {
      Sentry.captureException(
        exception,
        stackTrace: stackTrace,
      );
    } else {
      Sentry.captureMessage(message, level: SentryLevel.error);
    }
  }

  // Start a Sentry performance transaction
  static Future<T> traceAsync<T>(
    String name,
    String operation,
    Future<T> Function(ISentrySpan transaction) work,
  ) async {
    final transaction = Sentry.startTransaction(
      name,
      operation,
      bindToScope: true,
    );

    try {
      final result = await work(transaction);
      transaction.status = const SpanStatus.ok();
      return result;
    } catch (e) {
      transaction.throwable = e;
      transaction.status = const SpanStatus.internalError();
      rethrow;
    } finally {
      await transaction.finish();
    }
  }
}

Your app now has eyes on production. Every unhandled crash, navigation path, and sync operation is tracked. Next up, you will add accessibility and localization to make this app usable for everyone.

Add Accessibility, Localization, and Polish

Your app is feature-complete with full test coverage and crash reporting. The final layer that separates a tutorial project from a production-ready application is the user experience polish.

In this step, you will add accessibility support so screen readers can navigate your app, internationalize all user-facing strings into English and Spanish, implement system-aware dark mode, and add shimmer skeleton loading for a professional feel.

In this step, get ready to:
  • Add Semantics widgets and accessibility labels throughout all screens.
  • Set up Flutter l10n with English and Spanish ARB files.
  • Implement dark mode, shimmer skeleton loading, and empty state widgets.
Add Semantics widgets for accessibility

Screen readers like TalkBack on Android rely on a semantic tree to describe your UI to visually impaired users. Flutter generates a basic tree automatically, but custom widgets and icons need explicit labels.

  • Open lib/features/inspection/presentation/pages/inspection_list_page.dart.
  • Wrap each interactive element (FAB, delete icon, sync icon, filter chips) with a Semantics widget or add a semanticsLabel property.
  • Add the following accessibility labels to the inspection list page:
// In InspectionListPage build method

// FAB for creating new inspections
FloatingActionButton(
  onPressed: _createNewInspection,
  semanticsLabel: 'Create new inspection',
  child: const Icon(Icons.add),
),

// Sync status indicator in app bar
Semantics(
  label: 'Sync status indicator',
  child: Icon(
    isSyncing ? Icons.sync : Icons.cloud_done,
    semanticsLabel: isSyncing ? 'Syncing in progress' : 'All data synced',
  ),
),

// Each inspection card in the list
Semantics(
  label: 'Inspection: ${inspection.title}, status: ${inspection.status}',
  child: InspectionCard(
    inspection: inspection,
    onTap: () => _openInspection(inspection),
    onDismissed: () => _deleteInspection(inspection),
  ),
),

What does this code do?

The Semantics widget wraps elements that need custom descriptions for screen readers. The semanticsLabel property on Material widgets like FloatingActionButton and Icon achieves the same effect without an extra wrapper.

When TalkBack reads your inspection list, it announces each card with its title and status instead of just saying "button" or "image".

  • Open lib/features/inspection/presentation/pages/inspection_form_page.dart.
  • Add Semantics labels to all form fields and action buttons:
// In InspectionFormPage build method

// Title field with semantic label
TextFormField(
  decoration: const InputDecoration(
    labelText: 'Inspection Title',
    hintText: 'Enter a descriptive title',
  ),
  // Semantics are automatically derived from labelText
  style: Theme.of(context).textTheme.bodyLarge,
),

// Category dropdown with explicit semantics
Semantics(
  label: 'Select inspection category',
  child: DropdownButtonFormField<String>(
    decoration: const InputDecoration(labelText: 'Category'),
    items: categories.map((c) => DropdownMenuItem(
      value: c,
      child: Text(c),
    )).toList(),
    onChanged: (value) => _onCategoryChanged(value),
  ),
),

// Capture Location button
ElevatedButton.icon(
  onPressed: _captureLocation,
  icon: const Icon(Icons.my_location),
  label: const Text('Capture Location'),
  // Button text provides the semantic label automatically
),

// GPS coordinates display (read-only)
Semantics(
  label: 'Captured GPS coordinates: '
      '${state.latitude?.toStringAsFixed(4)}, '
      '${state.longitude?.toStringAsFixed(4)}',
  excludeSemantics: true,
  child: Row(
    children: [
      Text('Lat: ${state.latitude?.toStringAsFixed(4) ?? "--"}'),
      const SizedBox(width: 16),
      Text('Long: ${state.longitude?.toStringAsFixed(4) ?? "--"}'),
    ],
  ),
),

Why use excludeSemantics?

The GPS coordinates row has two Text widgets that a screen reader would read separately ("Lat: 40.7128", then "Long: -74.0060"). By wrapping them in a Semantics with excludeSemantics: true, you replace both individual readings with one combined announcement.

  • Verify that all text uses Theme.of(context).textTheme styles instead of hardcoded font sizes. This ensures dynamic text scaling works when users adjust their system font size.
  • Save both files.
  • Run the app on your emulator. Open Settings > Accessibility > TalkBack and enable it.
  • Navigate your app by swiping. Each element should announce its label clearly.

TalkBack not reading your labels?

Make sure you did not wrap a Semantics widget around a widget that already has its own semanticsLabel property set. Duplicate semantics can confuse screen readers.

Check that MergeSemantics is not accidentally merging labels from parent and child widgets into one unreadable string.

Why is TalkBack not reading my custom Semantics labels?

Set up Flutter l10n with ARB files

Flutter's localization system uses ARB (Application Resource Bundle) files to store translated strings. You define string keys in a template file, provide translations in locale-specific files, and Flutter generates type-safe accessor classes.

  • Add the localization dependencies to your pubspec.yaml under dependencies:
  • Also add generate: true under the flutter section of pubspec.yaml.
dependencies:
  flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true
  • Create a file called l10n.yaml in the root of your project with the following configuration:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false

What does this configuration do?

The arb-dir field tells Flutter where to find your translation files. The template-arb-file sets English as the source language. The output-localization-file names the generated Dart class file.

Setting synthetic-package: false generates the localization files directly into your source directory instead of a virtual package. This is the required approach for Flutter 3.41.5.

  • Create the directory lib/l10n/ if it does not exist.
  • Create lib/l10n/app_en.arb with all your English strings:
{
  "@@locale": "en",
  "appTitle": "Field Inspector",
  "@appTitle": { "description": "The app title shown in the app bar" },
  "inspectionListTitle": "Inspections",
  "createInspection": "Create Inspection",
  "editInspection": "Edit Inspection",
  "deleteInspection": "Delete Inspection",
  "inspectionTitle": "Title",
  "inspectionTitleHint": "Enter a descriptive title",
  "locationName": "Location Name",
  "category": "Category",
  "categoryStructural": "Structural",
  "categoryElectrical": "Electrical",
  "categoryPlumbing": "Plumbing",
  "categoryFireSafety": "Fire Safety",
  "categoryGeneral": "General",
  "statusDraft": "Draft",
  "statusInProgress": "In Progress",
  "statusCompleted": "Completed",
  "captureLocation": "Capture Location",
  "addPhoto": "Add Photo",
  "addItem": "Add Item",
  "scanAsset": "Scan Asset",
  "signOff": "Sign Off",
  "generateReport": "Generate Report",
  "syncNow": "Sync Now",
  "syncInProgress": "Syncing...",
  "syncComplete": "All data synced",
  "syncFailed": "Sync failed. Will retry.",
  "noInspections": "No inspections yet",
  "noInspectionsHint": "Tap + to create your first inspection",
  "save": "Save",
  "cancel": "Cancel",
  "delete": "Delete",
  "confirmDelete": "Are you sure you want to delete this inspection?",
  "errorGeneric": "Something went wrong. Please try again.",
  "errorNetwork": "No internet connection.",
  "login": "Log In",
  "signUp": "Sign Up",
  "email": "Email",
  "password": "Password",
  "logout": "Log Out",
  "settings": "Settings",
  "severity": "Severity",
  "severityLow": "Low",
  "severityMedium": "Medium",
  "severityHigh": "High",
  "severityCritical": "Critical",
  "notes": "Notes",
  "signerName": "Signer Name",
  "dashboard": "Dashboard",
  "inspections": "Inspections",
  "profile": "Profile"
}
  • Create lib/l10n/app_es.arb with the Spanish translations:
{
  "@@locale": "es",
  "appTitle": "Inspector de Campo",
  "inspectionListTitle": "Inspecciones",
  "createInspection": "Crear Inspeccion",
  "editInspection": "Editar Inspeccion",
  "deleteInspection": "Eliminar Inspeccion",
  "inspectionTitle": "Titulo",
  "inspectionTitleHint": "Ingrese un titulo descriptivo",
  "locationName": "Nombre del Lugar",
  "category": "Categoria",
  "categoryStructural": "Estructural",
  "categoryElectrical": "Electrico",
  "categoryPlumbing": "Plomeria",
  "categoryFireSafety": "Seguridad contra Incendios",
  "categoryGeneral": "General",
  "statusDraft": "Borrador",
  "statusInProgress": "En Progreso",
  "statusCompleted": "Completado",
  "captureLocation": "Capturar Ubicacion",
  "addPhoto": "Agregar Foto",
  "addItem": "Agregar Elemento",
  "scanAsset": "Escanear Activo",
  "signOff": "Firmar",
  "generateReport": "Generar Reporte",
  "syncNow": "Sincronizar Ahora",
  "syncInProgress": "Sincronizando...",
  "syncComplete": "Datos sincronizados",
  "syncFailed": "Sincronizacion fallida. Se reintentara.",
  "noInspections": "No hay inspecciones",
  "noInspectionsHint": "Toque + para crear su primera inspeccion",
  "save": "Guardar",
  "cancel": "Cancelar",
  "delete": "Eliminar",
  "confirmDelete": "Esta seguro de que desea eliminar esta inspeccion?",
  "errorGeneric": "Algo salio mal. Intente de nuevo.",
  "errorNetwork": "Sin conexion a internet.",
  "login": "Iniciar Sesion",
  "signUp": "Registrarse",
  "email": "Correo electronico",
  "password": "Contrasena",
  "logout": "Cerrar Sesion",
  "settings": "Configuracion",
  "severity": "Severidad",
  "severityLow": "Baja",
  "severityMedium": "Media",
  "severityHigh": "Alta",
  "severityCritical": "Critica",
  "notes": "Notas",
  "signerName": "Nombre del Firmante",
  "dashboard": "Panel",
  "inspections": "Inspecciones",
  "profile": "Perfil"
}
  • Now configure MaterialApp in lib/app.dart to use the generated localizations. Find your existing MaterialApp widget and add the localization delegates and supported locales:
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:field_inspector/l10n/app_localizations.dart';

// Inside your MaterialApp widget
MaterialApp(
  // ... existing properties (navigatorObservers, routes, etc.)
  localizationsDelegates: const [
    AppLocalizations.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: const [
    Locale('en'),
    Locale('es'),
  ],
)

What are localization delegates?

Each delegate handles a different layer of translation. AppLocalizations.delegate provides your custom app strings. GlobalMaterialLocalizations.delegate translates Material widget text (date pickers, buttons). GlobalCupertinoLocalizations.delegate handles iOS-style widget text.

Flutter uses the device locale to pick which ARB file to load. If the device is set to Spanish, your app automatically shows app_es.arb strings.

  • Run code generation to produce the localization classes:
flutter gen-l10n
  • You should see a generated app_localizations.dart file appear in your lib/l10n/ directory.
  • To use localized strings in your widgets, access them via AppLocalizations.of(context). For example, replace hardcoded text like 'Inspections' with AppLocalizations.of(context)!.inspectionListTitle.
  • Save all files and run your app. Change the emulator language to Spanish under Settings > System > Languages to verify the translations appear.

Seeing errors about missing AppLocalizations?

Make sure you added generate: true under the flutter section in pubspec.yaml. Without this, Flutter does not trigger code generation for l10n.

Also verify your l10n.yaml includes synthetic-package: false. The import path changes depending on this setting.

Help me fix AppLocalizations import errors in my Flutter l10n setup

Implement dark mode, shimmer loading, and empty states

Professional apps respect the user's system appearance preference and provide visual feedback during data loading. You will create a theme file, add shimmer skeleton placeholders, and build an empty state widget.

  • Create lib/core/theme/app_theme.dart with your light and dark themes:
import 'package:flutter/material.dart';

/// Light theme using Material 3 with indigo seed color
final ThemeData lightTheme = ThemeData(
  useMaterial3: true,
  colorSchemeSeed: Colors.indigo,
  brightness: Brightness.light,
);

/// Dark theme using Material 3 with indigo seed color
final ThemeData darkTheme = ThemeData(
  useMaterial3: true,
  colorSchemeSeed: Colors.indigo,
  brightness: Brightness.dark,
);

What does colorSchemeSeed do?

Material 3 generates an entire color palette from a single seed color. By setting colorSchemeSeed: Colors.indigo, Flutter creates harmonious primary, secondary, tertiary, surface, and error colors for both light and dark modes automatically.

Setting useMaterial3: true enables the latest Material Design system with updated component shapes, typography, and elevation handling.

  • Update lib/app.dart to use the new themes with system-aware switching. Find your MaterialApp and add the theme properties:
import 'package:field_inspector/core/theme/app_theme.dart';

MaterialApp(
  // Theme configuration
  themeMode: ThemeMode.system,
  theme: lightTheme,
  darkTheme: darkTheme,
  // Localization (from previous substep)
  localizationsDelegates: const [
    AppLocalizations.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: const [
    Locale('en'),
    Locale('es'),
  ],
  // Existing properties
  navigatorObservers: [SentryNavigatorObserver()],
  // ... routes, home, etc.
)

How does ThemeMode.system work?

When set to ThemeMode.system, Flutter reads the device's dark mode preference and automatically switches between your theme (light) and darkTheme values. No manual toggle is needed.

  • Now add shimmer skeleton loading to the inspection list page. Open lib/features/inspection/presentation/pages/inspection_list_page.dart and add a shimmer loading state:
import 'package:shimmer/shimmer.dart';

// Skeleton loading widget shown while inspections load
Widget _buildSkeletonLoading(BuildContext context) {
  return Shimmer.fromColors(
    baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
    highlightColor: Theme.of(context).colorScheme.surface,
    child: ListView.builder(
      itemCount: 5,
      padding: const EdgeInsets.all(16),
      itemBuilder: (context, index) {
        return Padding(
          padding: const EdgeInsets.only(bottom: 16),
          child: Container(
            height: 80,
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(12),
            ),
          ),
        );
      },
    ),
  );
}

// Empty state widget shown when no inspections exist
Widget _buildEmptyState(BuildContext context) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(
          Icons.assignment_outlined,
          size: 64,
          color: Theme.of(context).colorScheme.outline,
        ),
        const SizedBox(height: 16),
        Text(
          AppLocalizations.of(context)!.noInspections,
          style: Theme.of(context).textTheme.headlineSmall,
        ),
        const SizedBox(height: 8),
        Text(
          AppLocalizations.of(context)!.noInspectionsHint,
          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
            color: Theme.of(context).colorScheme.outline,
          ),
        ),
      ],
    ),
  );
}

What does Shimmer.fromColors do?

The Shimmer.fromColors widget creates an animated gradient sweep across its child. The baseColor is the resting state and highlightColor is the bright flash that moves across. Using theme colors ensures the shimmer looks correct in both light and dark mode.

The skeleton shows 5 placeholder cards that match the shape of real inspection cards. This gives users immediate visual feedback that content is loading.

  • Wire the skeleton and empty state into your BlocBuilder inside the inspection list page. Replace the loading and empty conditions:
// Inside BlocBuilder<InspectionListBloc, InspectionListState>
BlocBuilder<InspectionListBloc, InspectionListState>(
  builder: (context, state) {
    return state.when(
      loading: () => _buildSkeletonLoading(context),
      loaded: (inspections) => inspections.isEmpty
          ? _buildEmptyState(context)
          : _buildInspectionList(inspections),
      error: (failure) => Center(
        child: Text(AppLocalizations.of(context)!.errorGeneric),
      ),
    );
  },
),
  • Save all files.
  • Run your app with flutter run. Toggle dark mode in the emulator under Settings > Display > Dark theme. The entire app should switch to dark colors.
  • On initial load, you should see the shimmer animation pulsing across grey placeholder cards before real data appears.

Shimmer not visible in dark mode?

If the shimmer placeholder cards are invisible against the dark background, check that your baseColor uses Theme.of(context).colorScheme.surfaceContainerHighest instead of hardcoded Colors.grey. The theme-aware color adapts to both light and dark mode.

Why is my shimmer invisible in dark mode?

✔️ Awesome, I've got everything!

Great. Double check you have saved l10n.yaml, both ARB files, app_theme.dart, and app.dart.

ⓧ I'd like to double check the full code

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false
import 'package:flutter/material.dart';

/// Light theme using Material 3 with indigo seed color
final ThemeData lightTheme = ThemeData(
  useMaterial3: true,
  colorSchemeSeed: Colors.indigo,
  brightness: Brightness.light,
);

/// Dark theme using Material 3 with indigo seed color
final ThemeData darkTheme = ThemeData(
  useMaterial3: true,
  colorSchemeSeed: Colors.indigo,
  brightness: Brightness.dark,
);
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:field_inspector/l10n/app_localizations.dart';
import 'package:field_inspector/core/theme/app_theme.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

// MaterialApp with theme, localization, and Sentry observer
MaterialApp(
  themeMode: ThemeMode.system,
  theme: lightTheme,
  darkTheme: darkTheme,
  localizationsDelegates: const [
    AppLocalizations.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: const [
    Locale('en'),
    Locale('es'),
  ],
  navigatorObservers: [SentryNavigatorObserver()],
  // ... routes, home, etc.
)

Your app now adapts to the user's language, respects their dark mode preference, provides accessible labels for screen readers, and shows polished loading states. Next up, you will tackle the Secret Mission and add a statistics dashboard.

Secret mission

Add a Dashboard with Inspection Statistics

Your app captures inspections, but inspectors have no way to see their progress at a glance. Build a dashboard home screen with animated stat cards, a pie chart breaking down inspections by category, and a bar chart showing daily activity for the last 7 days. Wire it into a bottom navigation bar so the dashboard becomes the app's new landing page.

Clean Up Your Resources

Clean Up Your Resources

This project uses Supabase's free tier (500 MB database, 1 GB storage) and Sentry's free tier (5,000 errors/month). There are no ongoing costs for portfolio and demo use.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Supabase project ("field-inspector") with database, Storage buckets, and Realtime.
  • Sentry project (Flutter crash reporting).
  • Android Virtual Device (AVD) in Android Studio (~10 GB disk space).
  • Local Flutter project files (field_inspector folder on your Desktop).

Keep everything running

No action needed. Choose this if you are still actively building or plan to demo the app soon.

  • Supabase free-tier projects pause automatically after 1 week of inactivity. Visit your Supabase dashboard to unpause when you return.
  • Sentry's free tier has no pause mechanism. It continues collecting events up to the monthly limit.
  • Your AVD and local project files remain on disk. They consume storage but no cloud resources.
  • Push your Flutter project to GitHub for safekeeping and portfolio visibility.

Pause - I'll come back to this later

Free up local memory and disk without deleting your cloud resources.

  • Supabase will auto-pause your project after 1 week of inactivity. No manual action needed.
  • Close the Android emulator if it is running. This frees several GB of RAM immediately.
  • Push your Flutter project to GitHub before stepping away so your code is backed up.

Delete - I don't want to use this again

Remove all project resources and start fresh. This action is permanent for cloud resources.

  • Delete your Supabase project: open the Supabase dashboard, navigate to your project's Settings page, select General, scroll to the bottom, and click Delete Project. This removes your database, Storage buckets, and all associated data.
  • Delete your Sentry project: open sentry.io, navigate to your project's settings via the settings icon on the project page, and look for the delete option at the bottom of the project settings. This removes all crash reports and event data.
  • Delete the Android Virtual Device: open Android Studio, go to the Device Manager, click the menu icon next to your Pixel 7 AVD, and select Delete. This frees approximately 10 GB of disk space.
  • Delete your local project files by running this command in your terminal:
cd ~/Desktop
rmdir /s /q field_inspector

Push to GitHub first

Before deleting local files, push your project to GitHub if you want to keep it in your portfolio. Once deleted locally, the code is gone unless you have a remote backup.

That's a Wrap!

That's a Wrap!

You just built a production-grade, enterprise-level offline-first mobile application from the ground up. This is not a tutorial clone. This is the kind of app that companies pay $50-100k to have built, and you architected it yourself.

Here's what you accomplished:

  • Architected 7 features using feature-first Clean Architecture with the BLoC pattern, backed by a Supabase cloud backend with Auth, Row Level Security, Realtime subscriptions, and Storage.
  • Built a queue-based offline sync engine with last-write-wins conflict resolution, exponential backoff retry, and integrated 4 hardware systems (camera, GPS, QR scanner, digital signature) into a reactive Drift/SQLite local database.
  • Shipped with production-grade concerns: Sentry crash reporting with navigation tracing, full accessibility with Semantics, English/Spanish localization, PDF report generation, and a comprehensive test suite covering use cases, BLoC logic, and repository integration.
  • Secret Mission: Added an interactive statistics dashboard with fl_chart featuring pie charts, bar charts, and animated stat cards.

Ready to quiz yourself?