Second line empty
So I've been working on a personal budget tracker app for the past several months, and the single biggest pain point I kept running into wasn't UI design or state management — it was this: the app was useless whenever my phone had spotty connectivity.
I commute by subway. Signal drops constantly. I'd try to log an expense right after buying coffee and the app would just... spin. Or worse, silently fail and I'd forget to record it later. Every commercial budgeting app I tried had the same problem — they're cloud-first, and when the cloud isn't available, neither is your data.
If you're building a Flutter finance app (or any data-entry mobile app, really), this problem is worth solving properly. Here's what I learned.
Why offline-first matters more than you think
There's a difference between "works offline" and "offline-first". Most apps grudgingly tolerate offline mode — they'll cache some reads but refuse to write anything until the network comes back. Offline-first means your app is designed assuming the network is unreliable, and syncing is a background concern, not a gating requirement.
For a budget tracker specifically, this matters because:
- Expenses happen at the point of purchase, which is often in a store basement, on a train, or somewhere with terrible signal
- The longer the gap between spending and logging, the more you forget (or rationalize away)
- Financial data is sensitive — you don't necessarily want it dependent on a third-party server being up
The core challenge is: how do you persist data locally, keep your UI reactive, and optionally sync when connectivity returns — all without making your codebase a nightmare?
Local persistence: picking your storage layer
In Flutter, you've got a few realistic options for structured local data:
SQLite via sqflite — the most flexible, good for complex queries, but you're writing raw SQL and managing schema migrations yourself.
drift (formerly Moor) — type-safe SQLite wrapper with code generation. Great for anything relational. More boilerplate upfront but worth it.
Hive — NoSQL key-value store, very fast, zero native dependencies (works on all platforms including web). Great for simpler data shapes.
Isar — newer, fast, has a query language, works well with Flutter's reactive patterns.
For a budget tracker where the core entities are transactions and categories (and maybe budgets per period), I ended up using drift because I needed real queries — things like "sum of expenses by category this month" — that would've been painful with a pure key-value store.
Here's a simplified version of the schema:
// database/tables.dart
import 'package:drift/drift.dart';
class Transactions extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 1, max: 100)();
RealColumn get amount => real()();
TextColumn get categoryId => text()();
IntColumn get type => intEnum<TransactionType>()(); // income or expense
DateTimeColumn get date => dateTime()();
BoolColumn get isSynced => boolean().withDefault(const Constant(false))();
}
class Categories extends Table {
TextColumn get id => text()();;
TextColumn get name => text()();
TextColumn get icon => text()();
TextColumn get colorHex => text()();
@override
Set<Column> get primaryKey => {id};
}
The isSynced column is key — it lets you track which records need to be pushed to a backend when connectivity returns, without blocking the user from entering data.
Making the UI reactive to local data
One thing that took me longer than it should have to internalize: with drift, your queries return Streams, not Futures. This means your UI automatically rebuilds when data changes. You don't need to manually refresh anything.
// In your repository
Stream<List<TransactionEntry>> watchTransactionsForMonth(DateTime month) {
final start = DateTime(month.year, month.month, 1);
final end = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
return (select(transactions)
..where((t) => t.date.isBetweenValues(start, end))
..orderBy([(t) => OrderingTerm.desc(t.date)]))
.watch();
}
// And a derived query for the summary
Stream<double> watchMonthlyTotal(DateTime month, TransactionType type) {
final start = DateTime(month.year, month.month, 1);
final end = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
final query = selectOnly(transactions)
..addColumns([transactions.amount.sum()])
..where(transactions.date.isBetweenValues(start, end))
..where(transactions.type.equals(type.index));
return query.map((row) => row.read(transactions.amount.sum()) ?? 0.0).watchSingle();
}
In your widget, you pair this with a StreamBuilder (or use riverpod with StreamProvider, which is what I do in practice because it handles loading/error states more cleanly).
The result: you enter a transaction, it writes to local SQLite, the stream emits, the summary widget rebuilds. Zero network involved. Feels instant because it is instant.
The "how much do I have left today" problem
The specific UX goal I had was: open the app, immediately see how much budget you have left for the day. Not this month, not this week — today. Because that's the decision you're making when you're standing at a register.
This means you need to calculate:
daily_remaining = (monthly_budget / days_in_month) - expenses_today
But that naive formula breaks down. What if you spent way over budget last week? Should that affect today's display? I found it more useful to show:
daily_remaining = (monthly_budget - total_expenses_so_far) / days_remaining_in_month
This "rolling daily budget" approach is more motivating — it tells you what you can actually spend for the rest of the month given where you are now, rather than pretending each day is independent.
I keep this calculation in a simple domain service, not in the UI layer. Makes it easy to test.
Handling sync when you do have connectivity
If you want to sync to a backend (I use a simple Supabase project), the pattern I settled on is:
- All writes go to local SQLite first, with
isSynced = false - A background sync service watches network connectivity using
connectivity_plus - When the network comes back, it queries for all records where
isSynced = falseand pushes them - On success, it marks them as synced
The key design decision: never block the user on sync. The app doesn't wait for confirmation from the server. If the sync fails, the record stays marked as unsynced and the service will retry next time.
You do need to think about conflict resolution if the same app runs on multiple devices, but for a single-user personal finance app, "last write wins" is usually acceptable. Just make sure your records have a updatedAt timestamp so the server can sort it out.
State management: keeping it sane
I use riverpod because it plays well with streams from drift and handles async state cleanly. But the pattern that matters more than the specific library is keeping your layers clean:
- Database layer: raw drift queries, returns streams/futures
- Repository layer: wraps the database, exposes domain-friendly methods
- Provider layer: exposes streams to the UI, handles transformations
- UI layer: just reads state and dispatches events, no business logic
When I broke this and put "just a small calculation" in a widget, I always regretted it during testing.
A few things that bit me
Date handling: Store dates as UTC in the database. Display in local time. Sounds obvious, but if you forget, you'll get off-by-one errors at midnight that are incredibly confusing to debug.
Schema migrations: drift has a migration system, use it. Don't just delete and recreate the database during development — you'll train yourself into bad habits, and then when real users have the app, you'll have no idea how to migrate their data.
Large amount inputs: Mobile keyboards are painful for entering amounts. A custom numeric keypad widget that feels like a calculator is worth the effort. Users entering 1234 is better UX than 1,234.00 in a text field.
Testing the offline state: Use connectivity_plus's mock in tests, and actually test what happens when sync fails. It's easy to only test the happy path and discover the offline behavior is broken right when you demo it to someone.
What I actually shipped
I put all of this together into 家計簿アプリ (Budget Tracker) — an offline-first Flutter app focused specifically on the "what can I spend today" use case. The whole point is that it works on the subway, at the store, wherever. You open it, you know your number, you log what you spend.
The architecture described above is pretty much exactly what's running in production. Local SQLite via drift, reactive streams for the UI, optional Supabase sync in the background.
If you're starting from scratch
My honest recommendation for building something similar:
- Start with
drifteven if you think your data model is simple. You'll add queries you didn't anticipate. - Design for offline from day one. Retrofitting offline support onto a cloud-first architecture is genuinely painful.
- Get the "quick entry" flow perfect before anything else. In a budget app, if logging a transaction takes more than 3 taps, people stop doing it.
- The "daily remaining" number is more actionable than monthly totals for most people — worth thinking about what the primary metric of your app actually is.
The offline-first constraint sounds like a limitation but it actually simplifies a lot of things. When you're not waiting on network requests, your UX becomes more predictable, your tests become simpler, and your users stop getting random loading spinners.
I wrote a more detailed guide on my blog covering the drift setup, the sync architecture, and the daily budget calculation logic in full: https://mcw999.github.io/mcw999-hub/blog/budget-tracker-guide/
Top comments (0)