Overview

This guide covers the practical, day-to-day workflow for building features in the FieldForce Flutter app. It uses the existing Tasks feature (apps/fieldforce/lib/features/tasks/) as a concrete worked example throughout.

How a feature is organized

Every feature in the app follows the same three-folder structure. Here is the real Tasks feature:
Three layers, clear responsibilities. Each layer only knows about the one directly below it:
Presentation calls the domain interfaces. Data implements them. They never import each other.

Adding a new feature: step by step

Here is the process for adding a new feature, using “add a notes section to tasks” as an example.

Step 1 — Domain: define the data and operations

Start with the entity. Use @freezed for immutability and copy-with support:
Then define the repository interface — the list of operations the feature supports:
Rule: Reads that come from the local SQLite database return a Stream. Writes that go to the server return a Future<Result<void>>. This distinction is described in Core Functions — Error Handling.

Step 2 — Run code generation

After defining the entity with @freezed, generate the .freezed.dart file:
You will see a note.freezed.dart file appear next to note.dart. Do not edit it — it is auto-generated. Commit it to Git along with note.dart.

Step 3 — Data: implement the repository

Create the concrete implementation that reads from SQLite and writes via PowerSync:
Key things to notice:
  • _db.watch() returns a live stream — the UI updates automatically when data changes.
  • try/catch only lives here, in the data layer. Never in domain or presentation.
  • uuidV7() generates the ID on the client so the row has an ID even before the server sees it.
  • All timestamps are UTC (DateTime.now().toUtc()). The presentation layer converts to local time for display.

Step 4 — Wire it into Riverpod

Create a providers file that connects the repository to the DI system:
Run generation again to create notes_providers.g.dart:

Step 5 — Presentation: build the controller and screen

In your widget, read the provider:

Step 6 — Add to routing

Register the new screen in lib/routing/ (the central go_router route table).

When to run code generation

During active development, run the watcher so generation is instant:
Commit generated files. Files like *.freezed.dart and *.g.dart are checked in to Git. This means anyone checking out the repo can build immediately without running generation themselves.

How offline writes actually reach the server

When you write to SQLite (as in addNote() above), PowerSync doesn’t immediately call the server. Here is the full journey:
What happens offline? Steps 1–2 always happen immediately. Steps 3–8 are deferred until connectivity returns. The user sees their change in the UI right away (from local SQLite), and it syncs to the server when they are back online. What if the server rejects the write? See Rejected Writes below.

Rejected writes

Sometimes the server permanently rejects an operation — for example, a validation rule says the note body can’t be empty, or the user lost permission to edit that task before the offline write synced. When this happens:
  • PowerSync’s upload queue does not get stuck — the rejected operation is cleared.
  • The Go backend writes a record to a rejected_mutations table.
  • That record syncs back down to the device via PowerSync.
  • The app surfaces it as a notification: “One change couldn’t be applied.”
Think of it like a post office marking a package “Return to sender” and sending you a notification, rather than blocking all future deliveries. As a developer, you don’t need to handle this manually per-feature. The uploadData() implementation in core_database handles the acknowledgement. You only need to build UI if you want to show users their rejected mutations (which come in through a rejected_mutations reactive query).

Working with the package DAG

The project enforces strict rules about which packages can import which others. The short version:
  • core_models is imported by everything — it has no imports itself.
  • Data/domain packages (core_network, core_auth, etc.) never import core_ui.
  • Nothing imports from an apps/ folder.
  • core_location is only used by the fieldforce app, not digital_worker.
The rules are automatically checked every time you run:
If you accidentally import the wrong package, the analyzer will fail with a clear message pointing to the violation. Fix it before committing — the CI pipeline runs the same check.

Troubleshooting

Build errors after pulling new code? Generated files might be out of date:
The app shows stale data that doesn’t update? PowerSync’s reactive queries only work with _db.watch(), not _db.get(). Check that your data layer uses watch() for live data. Sync isn’t uploading my offline writes? Open the hidden diagnostics screen (shake the device in dev mode, or navigate to the diagnostics route). It shows the upload queue depth and last sync time. melos run analyze fails with a DAG error? The error message will name the file and the forbidden import. Remove the import and restructure: move shared logic to the appropriate core_* package, or pass data through the domain layer interfaces instead of importing the concrete implementation.