After months of coding nights and weekends, I finally shipped Expenses Meet to the Google Play Store — a collaborative, offline-first group expense splitter and personal finance tracker built entirely with Flutter.
In this post I want to share why I built it, the architectural decisions that shaped it, the painful mistakes, and the features I'm most proud of.
💡 Why I Built It
Every time I travel with friends or split rent with roommates, the same conversation happens:
"Wait, who paid for dinner?"
"Didn't you already pay me back for that?"
Apps like Splitwise exist, but I wanted something that also doubles as a personal ledger — a single place where I can track my own transactions AND handle group bills simultaneously. So I built Expenses Meet.
🏗️ Architecture at a Glance
The app follows a feature-first folder structure with BLoC as the state management layer.
lib/
├── core/ # Shared utilities, constants, routing
├── features/
│ ├── transactions/
│ ├── groups/
│ ├── loans/
│ └── settings/
└── main.dart
Offline-First with Hive + Firestore Sync
The biggest architectural decision was going offline-first.
- All data is persisted locally in Hive (a fast, lightweight NoSQL store for Flutter).
- When the user comes online, a
SyncServicepushes local changes to Cloud Firestore and pulls remote changes down.
The tricky part? Preventing data loss during sync. My first naive implementation would wipe local Hive boxes before writing remote data — which meant un-synced local edits got destroyed on refresh. 😬
The fix was to run syncLocalToCloud() before syncCloudToLocal(), rescue any pending local items, and restore them after the download. Obvious in hindsight, brutal to debug.
State Management: BLoC + Freezed
Every feature uses flutter_bloc with Freezed models. Freezed gives me:
- Immutable data classes
-
copyWithfor free - Pattern matching on states
- Generated Hive adapters
One important gotcha I hit: Hive field indices and backward compatibility. When adding a new non-nullable field to an existing Hive model, you must add defaultValue: on the @HiveField annotation:
// ✅ Correct — won't crash on existing data
@HiveField(13, defaultValue: false)
@Default(false)
bool isPending,
// ❌ Wrong — crashes with "type 'Null' is not a subtype of type 'bool'"
@HiveField(13)
@Default(false)
bool isPending,
Freezed's @Default only fires in the constructor — it doesn't help the Hive adapter when a field is missing from disk. I learned this the hard way after a production crash report.
✨ Key Features
1. Group Expense Splitting
Add a bill, choose a payer, and split it three ways:
- Equally — divide among selected members
- Exact amounts — assign custom amounts per person
- Percentages — weighted splits (60/40, etc.)
The settle-up engine computes minimum transactions to zero out all debts in a group, instead of tracking each individual IOU.
2. Personal Ledger + Linked Records
Every group action (expense paid, settlement, loan) automatically creates a matching record in your personal transaction history, fully linked so you can tap through from your ledger to the group context and vice versa.
3. Loans & Debt Tracking
Peer-to-peer loans with:
- Role-aware UI (lender vs borrower see different actions)
- Installment schedules with cadence tracking
- Repayment approval flow — counterparty must accept before the balance updates
- Push notifications for edits, deletions, and reminders
4. App Security
- Biometric / PIN lock with a 30-second grace period (so switching apps briefly doesn't lock you out)
- Balance masking in Privacy Mode
- Inline PIN pad on the lock screen (no modal sheets that bleed behind the lock overlay)
5. Real-Time Push Notifications (FCM)
Getting push notifications right on Android was the hardest single feature. Key lessons:
- Register
FirebaseMessaging.onBackgroundMessageas a top-level function (not a method), or Android ignores it. - Tapping a notification while the app is cold-starting requires a pending click queue — you can't navigate until the widget tree is mounted.
- Always add
FLUTTER_NOTIFICATION_CLICKintent filter toAndroidManifest.xmlor notification taps do nothing on some Android versions.
🛠️ Tech Stack
| Layer | Library |
|---|---|
| UI Framework | Flutter 3.x + Dart 3.x |
| State Management | flutter_bloc |
| Local DB |
hive + hive_flutter
|
| Remote DB | Cloud Firestore |
| Auth | Firebase Auth |
| Push Notifications | Firebase Cloud Messaging (FCM) |
| Crash Reporting | Firebase Crashlytics |
| Navigation | go_router |
| Charts | fl_chart |
| Models |
freezed + json_serializable
|
| PDF Export |
pdf package |
| Biometrics | local_auth |
| Typography | Outfit (Google Fonts) |
📉 Mistakes & Lessons
1. Hive key ranges matter.
Never use DateTime.now().millisecondsSinceEpoch as a Hive integer key — it exceeds 32 bits and throws HiveError. Use millisecondsSinceEpoch ~/ 1000 instead, and check for collisions.
2. Keyboard animation performance.
Wrapping the entire screen in a single setState listener for keyboard insets caused 60fps drops on every keystroke. The fix: isolate MediaQuery.viewInsetsOf(context) into a tiny wrapper widget and use ValueListenableBuilder for text controllers.
3. GlobalKey ownership with overlays.
Using GlobalKeys inside route-transitioning widgets + a ShowCaseWidget overlay caused Multiple widgets used the same GlobalKey crashes on every notification tap. Root cause: the showcase widget was being re-parented during navigation. Fix: move ShowCaseWidget to wrap the app root in main.dart.
4. Settle up, then communicate.
I built the whole settle-up calculation engine before building the share/receipt feature. Big mistake — the data shape the settle-up engine produces doesn't map cleanly to what users want to share in a WhatsApp message. Design the output format first.
🚀 What's Next
- iOS App Store release
- CSV / Google Sheets export
- Recurring transaction reminders
- Widgets for the home screen (foundation already in with
home_widget)
Try It
📱 Google Play Store: Expenses Meet
If you've built something similar or have questions about the offline-sync architecture or the BLoC patterns, drop a comment below — happy to dig into any of it.
Built with Flutter · Firebase · Hive · ❤️
Top comments (0)