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: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:
Rule: Reads that come from the local SQLite database return aStream. Writes that go to the server return aFuture<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:
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:_db.watch()returns a live stream — the UI updates automatically when data changes.try/catchonly 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:notes_providers.g.dart:
Step 5 — Presentation: build the controller and screen
Step 6 — Add to routing
Register the new screen inlib/routing/ (the central go_router route table).
When to run code generation
| You changed… | Run… |
|---|---|
A @freezed class | melos run generate |
A @riverpod provider or controller | melos run generate |
A slang locale file (*.i18n.json) | melos run generate |
| A plain Dart class (no annotations) | Nothing — no generation needed |
| A PowerSync schema definition | Nothing — PowerSync schema is plain Dart, no generation |
Commit generated files. Files like*.freezed.dartand*.g.dartare 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 inaddNote() above), PowerSync doesn’t immediately call the server. Here is the full journey:
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_mutationstable. - That record syncs back down to the device via PowerSync.
- The app surfaces it as a notification: “One change couldn’t be applied.”
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_modelsis imported by everything — it has no imports itself.- Data/domain packages (
core_network,core_auth, etc.) never importcore_ui. - Nothing imports from an
apps/folder. core_locationis only used by thefieldforceapp, notdigital_worker.
Troubleshooting
Build errors after pulling new code? Generated files might be out of date:_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.
Related resources
- Architecture & Layering — Deep dive into the layer rules and package DAG.
- Core Functions — How sync, auth, and notifications work under the hood.
- Getting Started — Environment setup and first run.