DEV Community

Cover image for How I Built a Local-First Journaling App with Flutter, Drift and Firebase
Ayesha Iftikhar
Ayesha Iftikhar

Posted on

How I Built a Local-First Journaling App with Flutter, Drift and Firebase

I build Mindleau, a free brain-dump journal and mood tracker for iOS and Android. From day one, it had one hard requirement: you should be able to open the app and start writing without creating an account or having an internet connection.

Journal entries are some of the most private things people type, and the moment someone needs to empty their head is probably the worst possible time to show them a sign-up form.

That requirement shaped the whole architecture. Here's how it's built, including the tradeoffs.

The stack

  • Flutter for iOS and Android from one codebase
  • Drift (typed SQLite) as the source of truth
  • Firebase Auth + Cloud Firestore for optional background sync
  • provider + ChangeNotifier / ValueNotifier for state. No Riverpod, no Bloc. The app is small enough that it didn't need them.

The central rule: the UI never talks to Firestore. Every screen reads and writes through a single repository, and the repository only writes to SQLite. Sync happens afterwards, in the background.

Screens ──► SanctuaryRepository ──► Drift (SQLite)   ◄── source of truth
                      │
                      └──(background)──► SyncService ──► Firestore
Enter fullscreen mode Exit fullscreen mode

1. Every syncable table carries its own sync state

Instead of a separate outbox table, each syncable row records whether it has been pushed:

class BrainDumpRows extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get remoteId => text().nullable()();
  TextColumn get content => text()();
  DateTimeColumn get createdAt => dateTime()();
  DateTimeColumn get updatedAt => dateTime().clientDefault(DateTime.now)();
  DateTimeColumn get deletedAt => dateTime().nullable()();   // soft delete
  TextColumn get mood => text()();
  TextColumn get focusTagsCsv => text()();
  TextColumn get syncStatus =>
      text().withDefault(const Constant('synced'))();       // pending_create | pending_update | synced
  DateTimeColumn get lastSyncedAt => dateTime().nullable()();
}
Enter fullscreen mode Exit fullscreen mode

Four columns carry the sync logic:

  • remoteId maps the local autoincrement ID to the Firestore document ID.
  • updatedAt drives conflict resolution (more on that below).
  • deletedAt is a tombstone. Hard-deleting a row locally would leave nothing to tell the server it's gone.
  • syncStatus makes "what still needs pushing?" a single indexed query.

2. Writes are local and synchronous; sync is fire-and-forget

Saving a brain dump is a plain SQLite insert, marked as pending, followed by a sync attempt the UI doesn't wait for:

Future<int> saveBrainDump({
  required String content,
  required String mood,
  required List<String> focusTags,
}) async {
  final now = DateTime.now();
  final id = await _database.into(_database.brainDumpRows).insert(
        BrainDumpRowsCompanion.insert(
          content: content,
          createdAt: now,
          updatedAt: Value(now),
          mood: mood,
          focusTagsCsv: _encodeList(focusTags),
          syncStatus: const Value('pending_create'),
        ),
      );
  unawaited(syncNow()); // never blocks the save
  return id;
}
Enter fullscreen mode Exit fullscreen mode

The user sees their entry immediately. Whether sync then succeeds, fails, or can't run because there's no signal changes nothing on screen.

3. Sync is pull-then-push, last-write-wins

syncNow() runs the same steps every time:

Future<void> syncNow() async {
  if (!await _canAttemptCloudSync()) return;
  try {
    if (!await service.ensureSignedIn()) return;
    await _pullRemoteChanges(service);
    await _pushPreferences(service);
    await _pushBrainDumps(service);
    await _pushMoodCheckIns(service);
    await _pushLearnProgress(service);
  } catch (error, stackTrace) {
    log('Sync failed', error: error, stackTrace: stackTrace);
  }
}
Enter fullscreen mode Exit fullscreen mode

Pulling first means remote changes (from another device) are merged before local pending rows are pushed. The merge rule is plain last-write-wins on updatedAt:

final existing = await (_database.select(_database.brainDumpRows)
      ..where((t) => t.remoteId.equals(remoteId)))
    .getSingleOrNull();

// Local copy is newer: keep it; it'll be pushed next.
if (existing != null && existing.updatedAt.isAfter(remoteUpdatedAt)) return;
Enter fullscreen mode Exit fullscreen mode

Tradeoff: LWW can lose an edit if the same entry is changed on two offline devices. For a personal journal, where one person is almost always on one device at a time, that's acceptable. A CRDT would add a lot of complexity to prevent a conflict that almost never happens here.

4. Anonymous auth first, email later

"No account required" and "sync across devices" pull in opposite directions. Firebase anonymous auth solves it:

  1. On first sync, the app signs in anonymously. The user never sees a form, but their data has a stable users/{uid} path in Firestore.
  2. If they later add an email, the anonymous account is linked to an email-link credential, so the same UID and data carry over.
  3. On a new phone, the email link restores that account.

If anonymous auth is disabled or unreachable, ensureSignedIn() returns false and sync is skipped. The app keeps working from SQLite either way.

5. Bound every network call

This bug took me a while to understand. Firestore and Firebase Auth calls have no built-in timeout. On a flaky connection that isn't fully offline, an await can hang forever, leaving a spinner with no way out. Every network call now goes through one wrapper:

const _networkTimeout = Duration(seconds: 15);

Future<T> _withTimeout<T>(Future<T> action) => action.timeout(
      _networkTimeout,
      onTimeout: () => throw const NetworkTimeoutException(),
    );
Enter fullscreen mode Exit fullscreen mode

Because the UI never awaits sync, a timeout just means "try again later". When connectivity comes back, a listener calls syncNow() and every row that isn't synced goes up.

6. Migrations are part of the product

Moving from "local only" to "local-first with sync" meant adding sync columns to tables that already held real user data. Drift's MigrationStrategy makes this explicit and versioned:

@override
int get schemaVersion => 5;

@override
MigrationStrategy get migration => MigrationStrategy(
      onUpgrade: (m, from, to) async {
        if (from < 2) {
          await m.addColumn(brainDumpRows, brainDumpRows.remoteId);
          await m.addColumn(brainDumpRows, brainDumpRows.syncStatus);
          // ...
        }
        if (from < 5) {
          await m.createTable(stillnessSessionRows);
        }
      },
    );
Enter fullscreen mode Exit fullscreen mode

One lesson: SQLite won't add a NOT NULL column without a SQL-level default, and Drift's clientDefault only exists in Dart. So updated_at needed a raw ALTER TABLE ... ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0 for the rows already on users' phones.

7. Not everything has to sync

Guided stillness sessions (a timer-based breathing pause) are local-only on purpose. They only feed streaks and the 30-day heatmap, so syncing them wasn't worth the extra code and the extra data leaving the device. Deciding what not to sync is part of the privacy design.

What I'd do differently

  • Start with the sync columns. Adding them in migration v2 was avoidable.
  • Store tags as a JSON column, not CSV. CSV was quick at first and has been awkward to work with since.
  • Write the timeout wrapper on day one. Hanging awaits are the worst kind of offline bug because they only show up on bad networks, never at your desk.

Takeaways

If you're building anything personal (journals, habit trackers, notes), local-first is less work than it sounds:

  1. Make SQLite the source of truth.
  2. Put a syncStatus and updatedAt on every row.
  3. Never let the UI wait for the network.
  4. Use anonymous auth so "no account" and "sync" can coexist.

You can see the result in Mindleau. It's free on iOS and Android, and you can start writing without signing up. Questions about the sync design are welcome in the comments.

Top comments (0)