Red Hawk Wallet (Flutter)
A Flutter rebuild of Red Hawk Wallet as a three-sided campus marketplace (student, vendor, admin) on iOS and Android. The original Android prototype updated balances on the client; this version moved that to the server.
A ground-up rebuild of Red Hawk Wallet in Flutter, turning the Android-only student prototype into a three-role app for student, vendor, and admin on iOS and Android, with every balance change moved to the server.
What I built
- Rebuilt the app in Flutter for iOS and Android, including a real iOS build with entitlements and signing through Xcode.
- Moved every balance change server-side through Cloud Functions and a Stripe sandbox integration, so the client can never write a balance.
- Designed the role system: one binary, with firestore.rules and the router enforcing student, vendor, and admin boundaries.
- Replaced the static ID QR with a short-lived, server-minted token.
Key decision
I chose Flutter because I wanted one codebase for Android and iOS and I already had experience building with Flutter and Firebase. For this project and team size, development speed and one consistent UI architecture mattered more than maintaining separate platform implementations. I didn't choose Flutter because Kotlin Multiplatform or React Native are incapable. It was the best fit for what I could build and maintain efficiently.
What this project taught me
- I started with service classes for business / Firebase logic and ChangeNotifier only where the UI actually needed reactive state. That kept the architecture understandable for a small team and avoided pulling in a large state-management framework before we needed one. At ~60 screens it was still workable, but the manual wiring made the trade-off visible; if the app grows much further I'd re-evaluate something like Riverpod rather than defend the original choice forever.
- Rebuilding a project is worth it when the architecture no longer matches the product. Rewriting just for newer technology isn't.
- Role-based apps get much easier to reason about when the authorization rules are designed before the screens.
- Anything that represents money needs a clear source of truth outside the UI.
- Simple architecture is valuable, but 'simple' doesn't mean refusing to change it once the app outgrows it.
The full reasoning
The problem, options, and constraints
Problem
The Kotlin Red Hawk Wallet worked, but it had three ceilings. It ran on Android only, so students on iPhone couldn't install it. Its tap-to-pay was simulated, with a client-side Firestore transaction doing the debit, which meant the app was trusted to move money. And it was student-only: no vendor or admin side, so it couldn't function as a campus commerce platform.
Observation
As Red Hawk Wallet grew past a student-facing prototype, I realized I wasn't building one simple app anymore. Student, vendor, and administrator roles had different permissions, and anything touching balances or transactions couldn't be treated like ordinary client-side state.
The question
How could I rebuild the app so three different user roles could share one product while keeping permissions, transactions, and data ownership clear?
User need
iOS and Android students need one wallet: add money, pay a vendor by scanning a QR, show a student ID, collect points. Vendors need to request and receive payments and run redemptions. Admins need to approve vendors and see transactions, users, and reports.
Constraints
- One codebase for iOS + Android, with a real iOS build (entitlements, signing) done through Xcode
- Clients must never write a balance: money moves server-side only
- Reuse Firebase (Auth, Firestore, Storage); add Cloud Functions + Stripe
- Three roles in one app, without one role reaching another's screens
Options considered
| Option | Trade-off |
|---|---|
| Kotlin Multiplatform: share logic, native UI per platform | Native UI on each platform, but still two UI layers and a newer toolchain |
| Flutter: one Dart codebase, one UI toolkit, both platforms | One codebase and one consistent UI architecture; the whole UI is re-implemented in Dart once |
| React Native | Ecosystem and web-skill overlap, but more native-module friction for the camera and payment SDKs |
Architecture
One binary, three role UIs (role on the user doc; enforced by firestore.rules and the router):
STUDENT wallet, add money, pay / send / receive, QR student ID, QR scanner,
transactions, points & rewards, offers + redemption, events, notifications, support chat
VENDOR dashboard, create payment request, QR payment, redemption scanner,
sales reports, offers, transactions, profile — gated behind an approval flow (/vendor/waiting)
ADMIN approve vendors, manage users / vendors / transactions / events / offers,
reports, reported issues, support-chat inbox
main.dart: init Firebase, Stripe (publishable key), push notifications -> RedHawkWalletApp
go_router — one redirect function:
not logged in -> /login (legal routes always allowed)
logged in, unverified -> /email-verification
role mismatch -> /home | /vendor | /admin (cross-role access blocked)
vendor not approved -> /vendor/waiting ; suspended -> auto sign-out
refreshListenable = auth ChangeNotifier
Money path — server-side only:
add funds -> CF createStripePaymentIntent -> Stripe PaymentSheet -> webhook confirmStripePayment credits wallet
pay vendor -> CF processPayment (student -> approved vendor) -> writes the transactions doc
firestore.rules: clients cannot edit a balance field
QR payloads (qr_payloads.dart), all server-verified:
receive redhawkwallet:receive:{uid}
student ID redhawkwallet:studentid:{token} token minted by CF issueStudentIdToken, rotates — not the uid
redeem redhawkwallet:redeem:{offerId}:{uid} -> offers/{offerId}/redemptions/{uid}, verifyOfferRedemption
Firestore: users/{uid}, wallets/{uid} { balance, points, updatedAt }, transactions, vendors, offers, events, support chats
University gate: UniversityEmailValidator — montclair.edu, or *.edu / *.ac.uk / *.edu.auThings that didn't work immediately
The Android build let the client perform the debit itself, inside a Firestore transaction.
- Initial approach
- Carry that over: do the balance change on-device, just in Dart.
- Problem with it
- A client-side debit means the app is trusted with money. Rules can narrow that, but the real fix is for the client to never touch a balance at all.
- Decision
- Move the entire money path to the server.
- Final solution
- payVendor calls a processPayment Cloud Function; add-funds goes through createStripePaymentIntent plus a Stripe webhook; firestore.rules rejects every client write to a balance field. The app can request a payment and read the result. It can't move money.
- Result
- Balance changes are atomic and server-authoritative, and the client is no longer inside the trust boundary for money.
A digital-ID QR that encodes the user's uid can be screenshotted and reused.
- Initial approach
- Put redhawkwallet:studentid:{uid} in the QR, like the receive code.
- Problem with it
- That's a static credential: anyone who sees it once can present it forever.
- Decision
- Make the ID QR a short-lived, server-minted token, not an identifier.
- Final solution
- issueStudentIdToken (Cloud Function) mints a rotating token; the QR carries redhawkwallet:studentid:{token}; the scanner resolves it server-side. Receive and redeem QRs stay static because they only work with a server check anyway.
- Result
- A screenshot of an ID QR goes stale; identity always resolves through the server.
Three roles (student, vendor, admin), but one app, and a vendor must not be able to reach admin screens (or vice versa).
- Initial approach
- Ship separate builds, or hide the other roles' screens in the UI.
- Problem with it
- Separate builds triple the release work; hiding screens in the UI isn't a security boundary. The routes still exist and Firestore is still one database.
- Decision
- One binary; role is data, and it's enforced in the two places that matter.
- Final solution
- Role lives on the user doc. firestore.rules scope every read/write by role (a vendor can't read another vendor's sales; only an admin can flip approval). The go_router redirect sends each role to its own tree and blocks cross-role paths, with an approval gate for new vendors and auto-logout for suspended accounts. The UI just renders whatever the router allows.
- Result
- Adding an admin screen is a route plus a rules clause, not a new app. The role boundary holds even if someone hand-types another role's URL.
Selected code decisions
Adding funds: PaymentIntent created server-side, wallet credited by webhook
// the client asks a Cloud Function for a PaymentIntent — it never sees Stripe secret keys
final res = await _functions
.httpsCallable('createStripePaymentIntent')
.call<Map<String, dynamic>>({'amount': amount});
await Stripe.instance.initPaymentSheet(/* clientSecret: res.data['clientSecret'] */);
await Stripe.instance.presentPaymentSheet();
// the wallet is credited later, by the confirmStripePayment webhook — not hereThe Stripe secret and the balance write both stay on the server. The app only presents Stripe's sheet and waits; the webhook is the single source of truth for 'the money arrived', so a client that dies mid-flow can't leave the wallet wrong.
Before vs after
Platform, payments, and scope
Before
Android only (Kotlin + Jetpack Compose). Simulated NFC tap-to-pay with a client-side Firestore transaction. Student-only.
After
One Flutter codebase on iOS (entitlements + signing via Xcode) and Android. QR payments; real card top-ups via Stripe; every balance change in a Cloud Function with firestore.rules blocking client writes. Student, vendor, and admin in one app.
Why: The payment model had to be redesigned rather than copied: once the app involved multiple roles and real wallet state, balance changes couldn't be something the client just updates. The transaction had to be authoritative first, with the UI reflecting the result, not the UI as the source of truth.
This page explains the why. GitHub has the proof of what was built.
View full repository