
Indie Devlog #02: Offline-First with Brick ORM and Supabase
When you design an app for solo practitioners — personal trainers, yoga instructors, mobile massage therapists — the assumption that your user has a reliable internet connection is a luxury you cannot afford. Basement gyms, client homes in rural areas, and subway commutes are where these people work. An app that breaks without connectivity is an app they will stop using.
This is the problem I had to solve for Voxoap: a voice-driven SOAP note generator that must work everywhere, online or off.
The architecture decision
The conventional approach for a Supabase-backed Flutter app is direct API calls. Auth, reads, writes — all hitting Supabase endpoints. This works fine when you are online but collapses the moment the connection drops.
For Voxoap, I chose Brick ORM as the primary data layer. Brick provides an abstract persistence interface backed by SQLite locally and Supabase as the remote sync target. The local database is the source of truth; the cloud is a replica.
The rule is simple: never write to Supabase from Flutter. Always go through Brick repositories.
How Brick works
Brick models are annotated Dart classes that describe both the local SQLite schema and the remote Supabase shape:
@ConnectOfflineFirstWithRest(
sqlite: SqliteSerializable(),
rest: RestSerializable(),
)
class Session {
@Sqlite(unique: true)
final String id;
final String userId;
@Sqlite(ignore: true)
final String? voiceRecordingPath;
final String? soapNoteSubjective;
final String? soapNoteObjective;
final String? soapNoteAssessment;
final String? soapNotePlan;
@Rest(ignore: true)
final bool isSynced;
final SessionStatus status;
Session({
required this.id,
required this.userId,
this.voiceRecordingPath,
this.soapNoteSubjective,
this.soapNoteObjective,
this.soapNoteAssessment,
this.soapNotePlan,
this.isSynced = false,
this.status = SessionStatus.recording,
});
}
Note the annotations: @Sqlite(ignore: true) on voiceRecordingPath ensures raw audio never syncs to Supabase storage. @Rest(ignore: true) on isSynced keeps that local-only flag out of the API payload. Brick handles the mapping automatically.
The core flow: voice to SOAP note
The critical path through the app works like this:
- User taps record. Flutter captures 16kHz mono WAV audio.
- On stop, the audio file is saved to a local path and a
Sessionis created in Brick withstatus: recordingand thevoiceRecordingPathset. - If online, the app immediately POSTs the audio to the FastAPI AI service. The response fills in the SOAP fields and Brick upserts the updated session.
- If offline, the session sits in Brick's local SQLite with no SOAP content. When connectivity restores, a recovery loop picks it up:
final pendingSessions = await sessionRepository.get(
Query.where('status', isExactly: 'recording'),
);
for (final session in pendingSessions) {
if (connectivityService.isOnline) {
final soap = await aiService.generateSoap(
session.voiceRecordingPath,
userModality,
);
await sessionRepository.upsert(session.copyWith(
soapNoteSubjective: soap.subjective,
soapNoteObjective: soap.objective,
soapNoteAssessment: soap.assessment,
soapNotePlan: soap.plan,
status: SessionStatus.draft,
));
}
}
Brick's offlineFirstWithRest provider handles the rest. Reads come from SQLite instantly, writes go to local first and sync to Supabase when connectivity permits.
What I gave up
Brick is not free. The trade-offs:
- Schema management is manual. Brick does not generate migrations from model changes. You write raw SQLite
ALTER TABLEstatements by hand. - Query API is limited. You cannot do joins or complex aggregations through Brick's query language. For the reporting dashboard, I read snapshots synced to Supabase and query Postgres directly through the API.
- Sync conflicts are opaque. Brick uses LWW (last-writer-wins) for conflict resolution. For Voxoap's data model, this is acceptable — sessions are created by one user, rarely edited simultaneously. But I would not use this for collaborative editing.
Where Brick shines
For an offline-first app with relatively simple relational models and single-author data, Brick is ideal. The developer experience is straightforward:
final session = Session(
id: uuid.v4(),
userId: currentUserId,
voiceRecordingPath: localAudioPath,
status: SessionStatus.recording,
);
await brickRepository.upsert(session);
That single upsert call writes to SQLite, queues a sync job, and returns immediately. The UI updates from the local store. The sync happens in the background. No loading spinners, no error states for connection drops, no retry logic scattered across the codebase.
The pattern is opinionated — offline-first or nothing. If your app can tolerate some latency and eventual consistency, it removes an entire class of edge cases from your mobile app. For Voxoap, that trade-off was worth making.
For suggestions and queries, just contact me.
