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/ValueNotifierfor 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
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()();
}
Four columns carry the sync logic:
-
remoteIdmaps the local autoincrement ID to the Firestore document ID. -
updatedAtdrives conflict resolution (more on that below). -
deletedAtis a tombstone. Hard-deleting a row locally would leave nothing to tell the server it's gone. -
syncStatusmakes "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;
}
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);
}
}
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;
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:
- 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. - If they later add an email, the anonymous account is linked to an email-link credential, so the same UID and data carry over.
- 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(),
);
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);
}
},
);
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:
- Make SQLite the source of truth.
- Put a
syncStatusandupdatedAton every row. - Never let the UI wait for the network.
- 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)