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.
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.
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.
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.
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
| Option | Trade-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 / Expo | One codebase for both platforms, but adds a cross-platform layer and native-module work for NFC and the camera |
| A responsive web app | No 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-sideThings 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 transactionBalances 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 balanceThe 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