James Carl Sitsit

Engineering · Case study

Building

FloatWatch: GCash float tracker for rural operators

FloatWatch is a Flutter app that lets rural GCash operators track cash and e-float balances without spreadsheets. Designed around the field workflows operators already use.

At a glance

Role
Solo: field research to working prototype
Scope
Cash + e-float reconciliation, offline-tolerant state
Constraints
Rural connectivity: offline-tolerant by design; flows had to mirror the paper notebook operators already trust

Stack

  • Flutter
  • Dart

Deep dive

At the end of a day running a GCash outlet, you are sitting with a phone full of receipt screenshots, a drawer of cash, and a notebook that is supposed to reconcile the two. The e-float on the phone should have moved by exactly the amount the cash drawer moved, in the opposite direction. It rarely lines up on the first count. You recount, you find the receipt you skipped, you recount again. That gap between the phone and the drawer is the margin an operator actually lives on, and I got tired of hunting for it by hand.

So I built FloatWatch. This post is how it works under the hood, and where I got it wrong before I got it right.

What it is

FloatWatch is a Flutter app that tracks the two balances a GCash Partner Outlet juggles all day: physical cash in the drawer, and the e-float sitting in the GCash app. Every transaction moves both. A cash-in (customer hands you cash, you send them GCash) drains your e-float and grows your cash. A cash-out does the reverse. At the end of the day the app tells you what your closing e-float should be, you enter what it actually is, and it colors the difference green, yellow, or red.

I built it for myself first. The plan is simple: if the flow actually holds up against my own daily mess, it can go out to other GPO owners after that. No point selling a reconciliation tool that cannot reconcile my own drawer.

The constraints that shaped it

Three things decided most of the design before I wrote much code.

Money has to be exact. Floating-point pesos are a bug waiting to happen, so every amount in the app, including the markup rates, is stored as an integer number of centavos. Pesos only exist at the display layer. A 1.00% markup is stored as 100, and the income on an amount is amount * rateValue / 10000. It reads a little awkward, but it never drifts by a fraction of a centavo.

It has to work offline. Outlets in rural areas do not have reliable data, and I did not want the app leaning on a server to do basic math. So the OCR runs on the device, the database is local SQLite, and there is no cloud call anywhere in the daily flow.

It has to feel like the notebook. Operators already trust a paper process. The app earns its place by being faster at the same thing, not by asking anyone to relearn their job.

Architecture on a napkin

The whole thing is one arrow pointing down:

UI screen
  -> Provider (ChangeNotifier / ViewModel)
    -> Repository interface
      -> SQLite (local)   ... a cloud impl could sit here later

The reason the repository is an interface and not just a database class is that I wanted a clean boundary between the app and its storage. In main.dart I build the local versions:

final ownerRepo = LocalOwnerRepository(dbHelper);
final staffRepo = LocalStaffRepository(dbHelper);
final transactionRepo = LocalTransactionRepository(dbHelper);

The boundary means the providers and screens depend on the interface, not on SQLite directly, which is worth having on its own. What it is not is finished cloud sync, or a promise that adding it is a one-file change. A real sync layer still has to handle conflict resolution, merge order, auth, and partial failures, and none of that is written. I called this a "one-file swap" in an earlier note and had to walk it back: the interface earns a clean seam, not a migration.

The stack, and why

  • Flutter and Dart, because I wanted one codebase and a real UI toolkit, not a web view pretending to be an app.
  • provider for state, because the app is a handful of ViewModels and I did not need anything heavier.
  • go_router for navigation, with every route as a constant so I never hand-type a path.
  • sqflite for local storage, since the data is relational and I wanted real queries, not a key-value store.
  • Google ML Kit for on-device text recognition, because it reads receipts with no internet and no per-scan cost.
  • crypto to hash the PIN so the raw PIN is not stored in plaintext. I want to be clear this is not strong credential protection: a plain SHA-256 over a 4-6 digit PIN is brute-forceable almost instantly, so on a local-only device treat it as basic obfuscation, not real password security. A unique salt plus a slow KDF (Argon2id or scrypt) would harden it and is the change I have not shipped — though against a 4-6 digit keyspace even that stays brute-forceable, so platform secure storage plus a rate limit is the better answer.

How a pile of receipts becomes transactions

This is the part I actually care about, so here is the whole path.

You start on the batch upload screen and pick a stack of receipt screenshots. Those paths go to OcrService.extractBatch, a thin wrapper over ML Kit that turns each image into raw text and, importantly, swallows individual failures instead of killing the batch:

try {
  final text = await extractText(imagePaths[i]);
  results.add((path: imagePaths[i], text: text));
} catch (_) {
  results.add((path: imagePaths[i], text: ''));
}

Each blob of text then goes to ReceiptParser.parse, which pulls out the amount, the reference number, any phone numbers, and the date, then classifies the transaction type. The classifier is layered on purpose. It starts from the receipt's own verb: a "Paid" receipt is a purchase like load or bills, a "Sent" receipt is money moving between people, and it narrows down from there.

The send-money case is the tricky one, because a cash in and a cash out look almost identical on paper. The single signal that separates them is whose GCash number appears on the receipt, and the classifier keys off that to decide which way the money actually moved. Getting that disambiguation right took a few passes against real receipts before I trusted it.

Then every parsed receipt gets a confidence score, just a weighted checklist of what came back clean:

if (amount != null) score += 0.30;
if (type != null) score += 0.30;
if (dateTime != null) score += 0.20;

Anything under 0.6, or missing an amount, type, or date, gets flagged for manual review with a reason attached. I never wanted the app to quietly guess and save a wrong number. A low score is not a failure, it is the app asking me to look.

Before anything saves, analyzeBatch runs two more checks. It dedupes on a composite key of reference number, amount, and type, both within the batch and against the database, so scanning the same screenshot twice does not double-count. And it checks the date on each receipt: if a receipt belongs to a different day, the app figures out whether that day is still open or already closed and routes it accordingly instead of dumping it into today.

You land on the review screen, fix anything that got flagged, and confirm. Only then does addBatchTransactions write them. It sorts by the receipt's real timestamp, uses that timestamp as the transaction time instead of "now," and snapshots the markup settings onto each row so old income stays correct even if I change my rates next month.

The hard part

Parsing GCash receipts was the whole fight, and it was worse than I expected.

The problem is that ML Kit gives you the text a receipt appears to contain, not the text it means. The little copy icon next to a reference number gets read as a stray 0 or O. Extra spaces show up inside phone numbers. And the worst one: GCash receipts carry ad banners, so ML Kit often reads an advertised promo amount before it ever reaches the real total. Grab the first peso value you see and you will book "99.00" from an ad instead of the actual load.

The dumb-looking fix I am oddly proud of handles the load receipts, which show an amount, a convenience fee, and a total. Instead of trusting position or labels, both of which the ad noise breaks, I lean on an arithmetic relationship the real figures on a receipt have to satisfy and a stray ad amount almost never does. That internal consistency is what tells the real total apart from a promo number that happened to be printed nearby, and it survived receipts that every label-based attempt of mine choked on.

The rest of the parser is honestly a wall of regexes with comments explaining which real-world receipt each one exists for, because I only found these cases by feeding it my own screenshots and watching what broke. That is the ceiling I know is there: the parser is tuned to the receipts I have seen, and a new GCash layout will need a new pattern. The confidence gate is what keeps that from being dangerous, since anything it cannot read confidently gets handed back to a human instead of saved wrong.

What I would do differently

Honestly, not much yet, and I think that is because the project is still young enough that the real regrets have not arrived. The structure has held up so far. The repository interface has already paid off in how clean the OCR feature dropped in, and keeping money as integer centavos has saved me from a whole category of bug.

The things I am watching, rather than regretting: cloud sync is still a TODO, so right now the data lives on one phone and a lost device is a lost ledger. And the parser will keep needing new patterns as receipts change. None of those are wrong decisions, they are just the bill I have not paid yet.

Numbers

The verified ones: 82 tests pass, most of them on the markup math and the receipt parser, and flutter analyze comes back with zero errors. I have run it against real GCash receipts on my own phone, which is how the parser learned every ugly case it now handles. I have not benchmarked OCR accuracy across a large set, so I would treat "it reads my receipts" as one honest data point, not a claim about every outlet's receipts.

Where it is now

It runs on a physical Android device, on-device OCR and all, with no internet needed. Two build days in, the daily loop works: scan, review, confirm, close the day, read the discrepancy. Next is running it against my own outlet long enough to trust the flow, then wiring the cloud sync the architecture is already shaped for. If it holds up for me, that is when it goes to other GPO owners.

Status

Building · tested on my own Android device

Have a project like this? Let’s talk