← Back to blog
Offline-First Flutter · Part 4 of 12
September 30, 202610 min read

Offline-First Flutter, Part 4: The Repository & Reactive UI

FlutterDartOffline-First

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. createNote doesn't await http.post. It writes a local row and returns. The user's note is "saved" the instant SQLite commits — offline or not. The isDirty flag 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 +createNote writes a local row → Drift's .watch() re-emits → notesProvider pushes a new AsyncData → 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 notes table. 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 references AppDatabase or an endpoint, the boundary has leaked.
  • The repository returns domain types (Note), not raw query rows or DTOs. (Here Drift's generated Note doubles 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 AsyncDataConsumerWidget 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 Riverpod StreamProviderAsyncValue); 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.