Skip to content
HK.
All projects

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.

2026FlutterDartgo_routerFirebaseCloud FunctionsStripeiOSXcode

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.

01

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.
02

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.

03

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

OptionTrade-off
Kotlin Multiplatform: share logic, native UI per platformNative UI on each platform, but still two UI layers and a newer toolchain
Flutter: one Dart codebase, one UI toolkit, both platformsOne codebase and one consistent UI architecture; the whole UI is re-implemented in Dart once
React NativeEcosystem 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.au
Things 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 here

The 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