Skip to content
HK.
All projects

Red Hawk Wallet (Android)

Campus ID, balances, events, and offers were scattered across separate systems — Red Hawk Wallet unified them in a single Android app.

2026KotlinJetpack ComposeMVVMFirebase AuthFirestoreFirebase StorageAndroid NFC

A native Android prototype I built with a six-person team, bringing a Montclair State student's campus ID, four balances, events, and offers into one app. I led feature branches and kept authentication, data, and UI responsibilities organized across the team.

01

What I built

  • Led a six-person team: split work by feature, reviewed code, and kept the Firebase data model consistent across the app.
  • Built the digital ID (QR generation and staff verification), the wallet balances screen, and the pay flow.
  • Put the email-verification check in one place in the navigation layer instead of on every protected screen.
  • Designed offline tap-to-pay using pre-issued signed tokens with a spend cap and expiry.
02

Key decision

I used native Android with Kotlin and Jetpack Compose because this version was specifically an Android build and I wanted direct experience with the platform rather than introducing cross-platform complexity. Firebase gave us authentication, Firestore, and storage without requiring a separate backend for every feature. With six people on the project, I split work by feature branch and kept authentication, data, and UI responsibilities organized so features could move without everyone editing the same files.

03

What this project taught me

  • Authentication isn't finished just because a user can log in. Protected features still need explicit authorization and verification checks.
  • Shared data models have to be agreed on early when several developers are building features against the same Firebase database.
  • Feature branches and pull requests work much better when everyone understands where one feature's responsibilities end and the next begins.
  • Building wallet-related features made me think more carefully about which state the client should be allowed to control and which state needs stronger validation.

The full reasoning

The problem, options, and constraints

Problem

A Montclair State student's campus life is split across separate tools: a plastic Red Hawk ID for building access and meal swipes, no easy way to check the Red Hawk Dollars / Flex / Bonus / meal-swipe balances tied to it, and paper flyers for events and offers. Each piece is handled somewhere different.

Observation

A lot of campus experience is split across different places: student ID, events, offers, other student services. That made me want to see what a single student-focused app could look like instead of treating each feature as its own separate tool.

The question

Could we build one student-focused app that brings useful campus features together while still keeping authentication, user data, and access control properly separated?

User need

Students need one place for the four balances, a digital ID a staff member can verify, and campus events and offers, with authentication, user data, and access control kept separate so features don't quietly inherit each other's permissions.

Constraints

  • Six-person team: work had to split by feature so people weren't editing the same files
  • Registration gated behind email verification; protected features must stay unreachable until verified
  • Student project: no backend budget, so Firebase's free tier for auth, database, and storage
  • A payment-shaped feature: a balance and its transaction history must never disagree

Options considered

OptionTrade-off
Native Android (Kotlin + Jetpack Compose)Direct platform experience and no cross-platform layer; Android-only, and the UI isn't reusable for an iOS build later
React Native / ExpoOne codebase for both platforms, but adds a cross-platform layer and native-module work for NFC and the camera
A responsive web appNo install, but no real NFC tap and a weaker digital-ID story
Architecture
Auth (Firebase) — register sends an email verification
  AppNav splash guard, checked on every launch and after login:
    no user -> Login | user not verified -> Email-verification screen | verified -> Dashboard  (back stack cleared)

Compose screen -> ViewModel -> Repository -> FirestoreDataSource -> Firestore
  one repository per area (Wallet, Transaction, User, Event, Offer, Notification);
  FirestoreDataSource is the only class that knows collection names + query shapes

Firestore
  users/{uid}                 profile + ID photo ref (photo bytes in Firebase Storage)
  wallets/{uid}               redHawkDollars | flex | bonus | mealSwipes   (stored balances)
  users/{uid}/transactions/*  append-only history
  events / offers             campus + off-campus

Pay path (tapAndPay / tapAndPayWithToken) runs inside one Firestore transaction:
  check balance -> deduct the account field -> write the transaction record, all-or-nothing

Digital ID: generated QR -> professor scanner screen -> student-verification result
Offline tap-to-pay: pre-issued signed OfflineTokens (amount cap + expiry), held in
  EncryptedSharedPreferences as available / used sets, spent over NFC, redeemed server-side
Things that didn't work immediately

Designing the verification and access flow so protected features (wallet, digital ID) couldn't be reached before a user was verified, including through back-navigation or a deep link, not just the normal path.

Initial approach
Guard each protected screen individually: check verification state where the screen is built.
Problem with it
That spreads the same check across every screen and treats the screen as the boundary. It's easy to add a new screen and forget the check, or leave a navigation path open.
Decision
Put the check in the navigation layer, in one place, ahead of the protected screens.
Final solution
The splash/auth guard resolves auth + verification state on every entry and picks the destination: no user -> Login; user but not verified -> the Email-verification screen (with resend); verified -> Dashboard. The back stack is cleared so there's no route back into the protected tree.
Result
Access is decided once, in front of the protected screens, instead of re-checked on each one, so there isn't a screen reachable in the wrong state.
Selected code decisions

Only the money path is wrapped in a Firestore transaction

// pay: read balance, deduct, log — all-or-nothing
firestore.runTransaction { txn ->
  val wallet = txn.get(walletRef).toObject<Wallet>()!!
  require(wallet.balanceFor(account) >= amountCents) { "insufficient" }
  txn.update(walletRef, account.field, wallet.balanceFor(account) - amountCents)
  txn.set(txRef, Transaction(delta = -amountCents, at = now()))
}

// loading events, listing past transactions: plain setDocument() / get() — no transaction

Balances are stored fields, not derived, so a deduction and its history entry can drift if one write fails. Wrapping just tapAndPay / tapAndPayWithToken in runTransaction makes that pair atomic; everything else is a normal read/write and doesn't pay the cost.

Offline tap-to-pay uses pre-issued signed tokens, not a live balance check

data class OfflineToken(
  val tokenId: String,     // UUID
  val userId: String,
  val amountCents: Long,    // spend cap
  val issuedAt: Long,
  val expiresAt: Long,     // short TTL
  val signature: String,   // server-signed (mock for now)
)
// EncryptedSharedPreferences: available_tokens / used_tokens
// spend: move available -> used, send over NFC; a server call redeems it against the real balance

The token carries its own guardrails (a fixed amount, a short expiry, and a signature to check), so a payment can be authorized on the device and reconciled against the real balance when it's redeemed server-side. Whatever a token can spend is capped and time-boxed, so the exposure from a lost or copied token is bounded.

This page explains the why. GitHub has the proof of what was built.

View full repository