The Repository & Reactive UI
This is Part 4 of the Offline-First Flutter series. We have a schema that migrates safely. Now we connect that local database to the screen — and we do it in a way that makes the central promise of offline-first literally true in code:
The UI reads and writes only the local database. It has no idea the network exists. Every list comes from a Drift stream; every edit writes a local row and returns instantly. When the sync engine (Parts 5–10) updates the local DB in the background, the UI just... updates, because it's watching the database, not the server.
The piece that enforces this boundary is the repository, and we'll drive the UI with Riverpod streams. By the end of this part, FieldNotes is a fully working offline app — sync comes next.
Stack: Drift + Riverpod 3.0, Flutter 3.38 / Dart 3.12. Assumes Riverpod basics — providers and
AsyncValue.
Why a repository? The single doorway
Analogy — the front desk. Imagine an office where employees (widgets) need files. Without a front desk, every employee walks into the archive room, the mailroom, and the courier office directly — chaos, and everyone has to know where everything lives. A repository is the front desk: employees ask it for what they need, and it knows whether to pull from the local archive (the DB) or, later, coordinate the courier (the sync engine). The employees never touch the back rooms.
The repository is the single doorway between the UI and the data layer. The UI depends on the repository's clean interface (
watchNotes,createNote…), not on Drift, not on HTTP. This boundary is what lets us add the entire sync engine later without changing a single widget — the UI was never talking to the network anyway.
The repository interface
Define what the UI needs, in domain terms — no Drift or HTTP leaking through:
abstract interface class NoteRepository {
Stream<List<Note>> watchNotes(); // reactive list for the UI
Stream<Note?> watchNote(String id); // one note (for the editor)
Future<Note> createNote({String title, String body});
Future<void> updateNote(String id, {String? title, String? body});
Future<void> deleteNote(String id); // soft delete (tombstone)
}
Notice every method is about notes, not about tables or endpoints. That abstraction is the point.
The implementation: local DB in, local DB out
Here's the offline-first heart. Reads return Drift's reactive stream. Writes update the local DB, set the sync metadata from Part 2, and return immediately — no network.
class DriftNoteRepository implements NoteRepository {
DriftNoteRepository(this._db);
final AppDatabase _db;
final _uuid = const Uuid();
// READ: straight from the local DB's reactive query.
@override
Stream<List<Note>> watchNotes() =>
(_db.select(_db.notes)
..where((n) => n.isDeleted.equals(false)) // hide tombstones
..orderBy([(n) => OrderingTerm.desc(n.updatedAt)]))
.watch();
// CREATE: write locally, mark dirty, return at once.
@override
Future<Note> createNote({String title = '', String body = ''}) async {
final note = NotesCompanion.insert(
id: _uuid.v4(), // client UUID (Part 2)
title: title,
body: body,
updatedAt: DateTime.now().toUtc(),
// isDirty defaults to true → the sync engine will push it (Part 6)
);
return _db.into(_db.notes).insertReturning(note);
}
// UPDATE: bump version + updatedAt, mark dirty.
@override
Future<void> updateNote(String id, {String? title, String? body}) async {
final current = await (_db.select(_db.notes)..where((n) => n.id.equals(id)))
.getSingle();
await (_db.update(_db.notes)..where((n) => n.id.equals(id))).write(
NotesCompanion(
title: title == null ? const Value.absent() : Value(title),
body: body == null ? const Value.absent() : Value(body),
updatedAt: Value(DateTime.now().toUtc()),
version: Value(current.version + 1),
isDirty: const Value(true),
),
);
}
// DELETE: tombstone, not a real delete (Part 2).
@override
Future<void> deleteNote(String id) async {
await (_db.update(_db.notes)..where((n) => n.id.equals(id))).write(
const NotesCompanion(isDeleted: Value(true), isDirty: Value(true)),
);
}
}
Look what's missing: any mention of the network.
createNotedoesn'tawait http.post. It writes a local row and returns. The user's note is "saved" the instant SQLite commits — offline or not. TheisDirtyflag is the only trace that it still needs to reach the server, and the sync engine (Part 6) will handle that entirely separately. This is offline-first in code.
Wiring it to Riverpod
Now expose the repository and its stream through providers. (This mirrors the Riverpod series; watchNotes() becomes a StreamProvider yielding an AsyncValue.)
// The database (one instance for the app):
final databaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase(openConnection());
ref.onDispose(db.close);
return db;
});
// The repository:
final noteRepositoryProvider = Provider<NoteRepository>((ref) =>
DriftNoteRepository(ref.watch(databaseProvider)));
// The reactive list the UI watches:
final notesProvider = StreamProvider<List<Note>>((ref) =>
ref.watch(noteRepositoryProvider).watchNotes());
And the UI — a plain ConsumerWidget that watches the stream:
class NotesScreen extends ConsumerWidget {
const NotesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(notesProvider);
return Scaffold(
appBar: AppBar(title: const Text('FieldNotes')),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(noteRepositoryProvider).createNote(title: 'New note'),
child: const Icon(Icons.add),
),
body: switch (notesAsync) {
AsyncData(:final value) => ListView(
children: [for (final n in value) NoteTile(note: n)],
),
AsyncError(:final error) => Center(child: Text('DB error: $error')),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
}
The whole offline app is now working. Tap
+→createNotewrites a local row → Drift's.watch()re-emits →notesProviderpushes a newAsyncData→ the list rebuilds with the new note. All offline, all instant, zero network. The loading spinner only ever shows while the local query first runs (milliseconds), never while waiting on a server.
The reactive chain, end to end
It's worth seeing the full propagation, because it's the mechanism the sync engine will plug into for free:
user taps + → repository.createNote() → INSERT into local SQLite
│
Drift notices the `notes` table changed
▼
watchNotes() stream re-emits the new list
▼
notesProvider (StreamProvider) emits AsyncData
▼
ConsumerWidget rebuilds → new note on screen
The payoff for sync: in Part 7 the sync engine will write server changes into the same
notestable. Because the UI watches the table (not the server), those background writes flow through this exact chain and update the screen — with no extra UI code. SSoT + reactive streams is why "the app magically updates after sync." We get it now, for free, by building the read path correctly.
Keeping the layers honest
A few discipline points that pay off across the rest of the series:
- The UI imports the repository interface, never Drift or
http. If a widget ever referencesAppDatabaseor an endpoint, the boundary has leaked. - The repository returns domain types (
Note), not raw query rows or DTOs. (Here Drift's generatedNotedoubles as our domain type; in bigger apps you'd map to a separate model.) - Writes are local-only in this part. The repository will later also poke the sync engine to "try syncing now," but it will still return before any network work — the user never waits.
- One database instance, owned by a provider and disposed with
ref.onDispose(autoDispose/lifecycle).
Think of the repository as the seam where, in the next parts, we'll insert the outbox, connectivity triggers, and sync — all behind the interface the UI already depends on. Good boundaries are what make a big feature like sync addable without a rewrite.
Practice Challenges
Challenge 1 — Find the leak. A widget does ref.watch(databaseProvider).select(...) directly. What principle is violated and how do you fix it?
Show solution
It bypasses the repository, coupling the UI to Drift. Move the query into the repository (watchNotes) and have the widget watch a provider that exposes it. The UI should depend only on the repository interface.
Challenge 2 — No-network create. A reviewer asks "where's the await http.post in createNote?" What do you tell them?
Show solution
There isn't one — and that's the point. createNote writes a local row and returns instantly; the isDirty flag marks it for the sync engine to push later, separately (Part 6). The user's action never waits on the network.
Challenge 3 — Reactive write. Trace what happens on screen when updateNote changes a note's title, and which Drift feature makes it automatic.
Show solution
updateNote writes the new title (and bumps version/updatedAt, sets isDirty). Drift's reactive .watch() notices the notes table changed and re-emits, so notesProvider emits new AsyncData and the list rebuilds with the updated title — no manual refresh.
Challenge 4 — Free sync UI. Explain why writing server-pulled notes into the local notes table will update the UI without any new UI code.
Show solution
The UI watches the local table (the SSoT), not the server. Any change to that table — including background sync writes — makes watchNotes() re-emit, propagating through notesProvider to the widget. The read path is already reactive, so sync writes reuse it for free (Part 7).
Challenge 5 — The seam. Where will the sync engine attach without changing widgets, and why is that possible?
Show solution
Behind the repository (and triggered by connectivity, Part 5). Because the UI depends only on the repository interface and the reactive notesProvider, the sync engine can read dirty rows, push/pull, and write results into the DB — all without the UI knowing. Good boundaries make sync additive.
Questions to test yourself
Q1 (basic). What is the repository's role in the architecture?
Show answer
It's the single doorway between the UI and the data layer — the only thing the UI talks to. It exposes domain operations (watchNotes, createNote…) and hides Drift/HTTP, so the UI is decoupled from storage and (later) sync.
Q2 (basic). Where does the notes list come from, and where does it never come from?
Show answer
From the local database via Drift's reactive watchNotes() stream (exposed as a StreamProvider). It never comes directly from the network.
Q3 (intermediate). Why does createNote return instantly even offline?
Show answer
Because it only writes a local SQLite row (and sets isDirty) — no network call. The note is durably saved the moment the local write commits; pushing it to the server happens later in the sync engine.
Q4 (intermediate). How does an offline write end up on screen, step by step?
Show answer
Repository write → local INSERT/UPDATE → Drift detects the table change → watchNotes() stream re-emits → notesProvider emits new AsyncData → ConsumerWidget rebuilds with the change. The reactive .watch() drives the whole chain automatically.
Q5 (advanced). Why can the entire sync engine be added later without changing any widget?
Show answer
Because the UI depends only on the repository interface and watches the local DB, never the network. The sync engine sits behind that boundary: it reads dirty rows, talks to the server, and writes results into the same local table — whose reactive streams already update the UI. The boundary makes sync purely additive.
Q6 (advanced). Why keep the repository returning domain types rather than raw HTTP DTOs or query rows?
Show answer
To keep the UI independent of how data is stored or fetched. Returning domain types means changing the DB schema, the API shape, or even swapping the sync strategy doesn't ripple into the UI. It also lets the repository be the place that reconciles local + remote representations behind a stable contract.
Wrapping up
- The repository is the UI's single data doorway; the UI depends on its interface, never on Drift or HTTP.
- Reads come from Drift's reactive
watchNotes()stream (exposed via a RiverpodStreamProvider→AsyncValue); writes update the local DB, set sync metadata, and return instantly with no network. - This makes FieldNotes a fully working offline app right now —
+adds a note, edits and deletes work, all local and instant. - The reactive chain (write → Drift
.watch()re-emit → provider → rebuild) is the exact path background sync will reuse, so sync updates the UI for free. - Clean boundaries mean the whole sync engine attaches behind the repository without touching a single widget.
In Part 5 we add the sync engine's trigger: detecting connectivity — why "wifi connected" isn't the same as "online," and how to produce a trustworthy signal for when to sync.