Card Stash: Building a Loyalty Card Wallet That Never Phones Home
Everyone has a drawer of loyalty cards. Tesco, Boots, the gym, the local coffee shop. Most of us have three or four apps on our phones for the same purpose, each demanding an account, tracking our purchases, and sending push notifications about points we will never redeem.
Card Stash is a loyalty card wallet that does one thing well: store your barcodes so you can scan them at the till. No accounts, no cloud, no analytics. Everything encrypted on your device.
This is the story of how it was built, the process behind it, and the technical details behind features like on-device OCR and payment card rejection.
The process
Card Stash was built almost entirely with AI. Not in the sense of “I typed a prompt and an app appeared,” but in the sense of using AI as a thinking partner at every stage.
The process started in Claude’s web interface, working through ideas conversationally. What should the app do? What should it refuse to do? What are the security implications of storing barcode data? Those conversations produced a product spec, a security model, an architecture decision log, a build order with acceptance criteria per phase, and a visual style guide - all as structured documents in a docs/ folder.
With the documentation in place, the actual development happened in Claude Code. A project setup skill scaffolded the Claude Code configuration, and then development moved quickly because the AI had clear, detailed context about what to build and why. The spec said what each screen should do. The build order said what sequence to build them in. The architecture doc said which packages to use and which alternatives had been considered and rejected.
This workflow - think in conversation, document thoroughly, then build with the documentation as context - turned out to be dramatically faster than either writing code from scratch or trying to prompt an AI without clear specifications. The documentation was not overhead. It was the thing that made accurate development possible.
The constraint that shaped everything
Card Stash has a single architectural constraint that drove every other decision: no network calls. Not “minimal network calls” or “optional sync.” Zero. No HTTP clients, no Firebase, no crash reporting, no analytics, no CDN-hosted assets. Nothing leaves the device unless the user explicitly taps the share button.
This sounds extreme for a modern app. It is. And it simplifies almost everything.
No network calls means no server to maintain, no API keys to rotate, no GDPR data processing agreements, no privacy policy caveats about “anonymised usage data.” The privacy story is one sentence: your cards stay on your phone.
It also means every feature has to work offline. There is no “check for updates” fallback, no “retry when connected” error state. If it can’t run on a phone in aeroplane mode, it can’t go in the app.
How it works
Scanning a card
Point the camera at a barcode. The app detects it, identifies the barcode format (QR, Code 128, EAN-13, and six others), and drops you into the form with the number and barcode type pre-filled.
Behind the scenes, while the barcode scanner does its work, the camera frame is also passed to an on-device OCR engine. This extracts the card name (e.g. “Tesco Clubcard”) and expiry date (e.g. “EXP 12/25”) from the text printed on the card. Both fields auto-populate alongside the barcode number. The whole process takes under a second.
For cards without a barcode - just a printed number - there is a Take Photo button that opens the device camera. The photo goes through OCR only. If a number is found, it is stored as a display-only card (the number shown as text, no barcode rendered).
You can also pick a saved photo from your gallery. The app runs barcode detection and OCR in parallel, using whichever finds results.
Displaying a card
This is the most important screen in the app. When you tap a card, the barcode fills the screen and the display brightness jumps to maximum. The cashier scans it. You tap to dismiss. The brightness returns to where it was.
Getting this right required more care than expected. The brightness must be saved before forcing and restored exactly on dismiss - including back navigation, swipe dismiss, and any error path. The barcode must render at maximum safe width with quiet zones preserved (EAN-13 in particular needs space either side for reliable scanning). The card number is always displayed as text below the barcode as a fallback for scanners that struggle.
Encrypted storage
All card data is encrypted at rest using AES-256 via Hive CE, a local key-value database for Flutter. The encryption key is a random 256-bit value generated on first launch and stored in the device’s secure enclave (iOS Keychain or Android Keystore).
This is deliberately simpler than the alternative - deriving a key from a user passcode - which would require implementing key derivation, a passcode entry flow, and passcode reset logic. For a loyalty card wallet, that complexity is disproportionate.
On-device OCR
The OCR runs entirely on-device using Google ML Kit’s text recognition model, which ships bundled in the app binary (adding roughly 260KB). No images are uploaded. No API key is needed. It works in aeroplane mode.
How text recognition works
The process has three stages. First, a convolutional neural network scans the image for regions that look like text - clusters of pixels with consistent stroke width, horizontal alignment, and contrast against background. Second, for each detected region, a recognition pass identifies individual characters using contextual models that consider surrounding characters (so a smudged “e” next to “xp” is likely read correctly because “exp” is a common sequence). Third, the result comes back as structured text with bounding boxes and confidence scores.
Turning raw text into card data
ML Kit gives you raw text. It does not know what a card number is. That domain logic lives in a pure Dart function called parseText() that is completely separate from ML Kit and fully unit-testable without a device.
The parsing heuristics are straightforward:
- Card number: find all digit sequences (stripping spaces and hyphens), take the longest one with 8 or more digits. Loyalty card numbers are almost always the longest digit sequence on the card.
- Expiry date: regex for patterns like
EXP 12/25,VALID THRU 03/2027, or standalone12/25. Two-digit years assume 2000s. Invalid months are rejected. - Card name: first text line containing only letters and spaces that is not made up entirely of noise words (MEMBER, CARD, NUMBER, VALID, THRU, EXP, POINTS, REWARD, LOYALTY).
Separating the ML model (pixels to text) from the parsing logic (text to structured data) is a pattern worth stealing. The model is a black box you cannot unit test. The parsing is deterministic code with 19 tests that run in milliseconds. If the OCR engine were swapped out, only the image-to-text layer changes.
OCR output is treated as suggestions, not facts. All fields are pre-populated but editable. The user always has the final say.
Payment card rejection
A loyalty card wallet that accidentally stores credit card numbers is a liability. Card Stash runs a two-layer check on every manually entered card number:
- Luhn algorithm - the checksum used by every major payment network. If the number passes Luhn, it might be a payment card.
- BIN detection - the first 4-6 digits identify the card issuer. Card Stash checks against Visa, Mastercard, Amex, Maestro, and Discover prefix patterns.
A number that passes both checks is hard-rejected. The card is not stored under any circumstances. The message is clear: “This looks like a payment card. For your security, Card Stash doesn’t store credit or debit cards. Use Apple Pay or Google Wallet instead.”
There is no “store anyway” option. This is deliberate.
Some legitimate loyalty cards coincidentally pass Luhn (roughly 5% of cases). These users can select “Display Only” barcode type and enter the number manually. An annoying false positive is vastly preferable to storing someone’s Visa number in a loyalty card app.
Export and import
Without export and import, moving to a new phone means losing all your cards. The encryption key lives in the device’s secure enclave and cannot be extracted, so the encrypted database alone is unrecoverable on a new device.
The export flow encrypts your cards with AES-256-GCM using a key derived from a passphrase you choose via Argon2id. This is completely separate from the device encryption key. The passphrase is never stored. If you forget it, the export file is unrecoverable.
The exported .card-stash file includes an HMAC signature covering the version, timestamp, and encrypted payload. On import, the signature is verified before decryption is attempted - a wrong passphrase fails at the signature check, not mid-decryption.
Import supports two modes: Replace All (clean migration to a new phone) and Merge (add cards from the file, skip duplicates matched by normalised card number).
On iPhone, AirDrop makes device migration effortless. On Android, the share sheet offers Google Drive, nearby share, and anything else installed.
The stack
Card Stash is built with Flutter, targeting iOS and Android from a single codebase. Key dependencies:
| Concern | Solution |
|---|---|
| Local storage | Hive CE (AES-256 encrypted) |
| Encryption key | Flutter Secure Storage (device secure enclave) |
| Barcode scanning | Mobile Scanner |
| Barcode rendering | Barcode Widget |
| On-device OCR | Google ML Kit Text Recognition |
| State management | Riverpod |
| Export/import crypto | Argon2id KDF + AES-256-GCM |
| Notifications | Flutter Local Notifications |
Every dependency had to justify its inclusion. No package was added without a clear reason. The result is an app that builds quickly, runs without a network, and has a small binary footprint.
Open source
Card Stash is MIT licensed and open source on GitHub. For an app that stores card data encrypted with no way for anyone (including me) to access it, open source is not a nice-to-have. It is the only credible way to back up the privacy claim. Anyone can read the code and verify that no data leaves the device.
What I learned
The zero-network constraint was liberating, not limiting. It eliminated entire categories of decisions (which analytics provider, which crash reporter, which sync strategy) and made the app fundamentally simpler. Most apps that “need” a network connection don’t. They need a network connection for the business model, not for the user.
Brightness management is surprisingly hard. Saving, forcing, and restoring screen brightness across every dismiss path (back button, swipe, error, app backgrounding) took more iterations than any other feature.
Payment card rejection needs to be aggressive. The cost of a false positive (user has to select Display Only) is mild annoyance. The cost of a false negative (someone’s Visa number stored in a loyalty card app) is a security incident. Err hard towards rejection.
Separating ML inference from domain logic is essential. The OCR model is a black box. The parsing code is deterministic and testable. Keep them apart and you can iterate on the logic without touching the model integration.
Card Stash is open source on GitHub. Build it yourself, or read the code to verify that nothing leaves your device.
Related reading: Sunlight: One Design System, Many Surfaces and It Started with a Muddy Stick are two more apps I built solo, both offline-first and made to respect the people using them. The Coffee Link is the ethos behind all of them. I Built My Family a Chief of Staff. It Runs on a Raspberry Pi. is another thing built because I wanted it, not because a product team asked for it.