There was one shopping-list workflow the app still didn't respect: the household that already has a list — handwritten, on the fridge, in someone else's handwriting. Retyping it into an app is exactly the kind of chore that makes people close the app.
So: point the camera at the paper. This is the app's third input modality — voice (Part 5), receipt camera (Part 14), and now list camera — and the headline is how little of it was new. The feature was mostly assembly, and the few genuinely new decisions are the post.
TL;DR — Every piece existed: the capture hook pattern (pick → resize → upload → parse), the vision-Lambda-behind-a-mutation pattern, the item shape, the editable review sheet. The key decision: the list photo returns the voice pipeline's contract (no prices, no store, no totals), so the voice review sheet renders it unchanged — shapes belong to review experiences, not input devices. Inversions from the receipt path: higher fidelity (2000px @ 0.75 — handwriting needs detail print doesn't) and opposite retention (the photo is deleted the moment it's parsed). Plus: re-scan-proof dedupe, an honest
{items: []}for unreadable photos, and two Android traps that had nothing to do with vision.
(Part 20 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
Mostly assembly — and that's the point
The build inventory, from the plan doc, was a table of things already on the shelf:
| Need | Already built |
|---|---|
| Camera/library, resize, S3 upload, error + cancel handling | the receipt scan hook |
| Vision Lambda behind a custom mutation | the receipt parser |
Item shape {name, quantity, unit, category, note}
|
the voice parser |
| Editable review rows before anything writes | the voice sheet's ReviewList
|
| Sheet rules (no accidental dismissal, safe-area insets) | the shared sheet component |
One new prompt, one new Lambda wired like the last one, one new hook mirroring an existing one. When the third instance of a pattern costs a day instead of a week, that's the architecture paying out — the pipeline from Part 14 turned out to be a shape, not a feature.
The key decision: whose contract?
A photographed shopping list could have returned the receipt parser's shape — it's the same "camera → vision → items" pipeline. It deliberately doesn't. A shopping list has no prices, no store, no totals; its output is exactly what the voice parser already returns:
// Photo of a written shopping list (S3 key) → vision → items to add.
// Returns the parseShoppingList shape, not the receipt one — a list carries no
// prices, store or totals, so the client reuses the voice review sheet.
// Returns AWSJSON: { items: [{name,quantity,unit,category,note}] }
parseListImage: a
.mutation()
.arguments({ key: a.string().required() })
.returns(a.json())
So ReviewList — until now a private detail of the voice sheet — was extracted into its own component (a pure move, no behavior change) and gained a second caller. Voice and photo now converge on one review surface; the only fork is the retry button's icon, chosen by a source prop because the icon library needs a static literal.
The principle, restated from Part 14 and now proven twice: design output shapes per review experience, not per input device. Two inputs that end in the same human decision should end in the same component.
Two inversions from the receipt path
Fidelity: up. Receipts compress to ~1500px at JPEG 0.6 — thermal print is high-contrast and survives it. Handwriting doesn't: the list photo uploads at 2000px at 0.75, because a hurried ballpoint "courgettes" needs detail that a till printer never does. Same pipeline, per-modality tuning.
Retention: opposite. Part 14 kept the receipt photo forever — a receipt is a record, and its S3 key lives on the row. The list photo is deleted the moment it's parsed: it has served its entire purpose once the items exist, so keeping it is storage cost and privacy surface for nothing. And a failed delete is logged, never surfaced — the items are already in the user's hands, and a cleanup hiccup must not turn a successful scan into an error message.
Same verb — "photograph the paper" — and two opposite lifecycle policies, each argued from what the artifact is. That contrast is my favorite thing in this feature.
Re-scan-proof, and honest about failure
Two guardrails in the review step:
- Items already on the list are flagged and excluded from the add — so scanning the same fridge photo twice can't silently double the list. The check is against unchecked items only: something already ticked off has been bought, and wanting it again is legitimate, not a duplicate.
-
An unreadable photo returns
{items: []}, never a guess. Same anti-hallucination valve as the receipt prompt: given a legal way to say "this isn't a list" (or "I can't read this"), the model stops inventing groceries from a photo of the dog. The client renders an honest empty state instead.
And one repeat scar, now a checklist item: the new list-photos/{identityId}/* storage prefix had to be added both to the storage rules and to the group-role ManagedPolicy from Part 14's AccessDenied war story. The first owner-scoped prefix cost a day of IAM archaeology; the second cost two lines, because the convention was written down.
The two traps (neither was about vision)
The feature's actual debugging time went to two Android platform traps around the capture bar, which grew to two rows to fit the third button (field + submit, then Barcode / List / Speak — three buttons on one row had squeezed the text field to ~196pt; two rows gave it ~304pt, and the action row collapses while the field is focused, buying the space back exactly when the keyboard makes it scarce).
Trap 1: React Native core's KeyboardAvoidingView measures zero on modern Android. Core KAV infers the keyboard overlap from the window resizing — and under Android's now-mandatory edge-to-edge, the window doesn't resize; it receives insets. So core measures no overlap and moves nothing, and the keyboard covers your bar. The fix is react-native-keyboard-controller for both halves — its sticky translation on iOS, its own KAV on Android — wrapped once in a shared component so no screen re-solves it.
Trap 2: tap-to-dismiss over a camera preview must be a sibling, never a wrapper. Wrap a camera view in a Pressable to dismiss the keyboard and it works beautifully — on iOS. On Android, the native preview surface consumes the touch before the parent ever sees it. The dismiss layer has to be an overlay on top of the camera as a sibling. One of those bugs that's invisible in the iOS simulator and total on a real Android phone.
What I took away
-
The third instance of a pattern is the audit of the first. Voice → receipt → list-photo: each reuse got cheaper, and the reusable pieces (
ReviewList, the capture hook shape, the prompt skeleton) revealed themselves under pressure. - Output shapes belong to review experiences. Choose the contract by asking "what does the human decide next?", not "which device produced this?"
- Retention is an argument, not a default. Record → keep; means-to-an-end → delete, and never let cleanup failure poison success.
- Tune per modality inside a shared pipeline. Handwriting earns 2000px; thermal print doesn't.
- Write your IAM scars into conventions — the second prefix was two lines because the first was a day.
Next up
Part 21 is the war story this series has owed for a month: the EAS build that failed on four packages that never existed — npm ci, bundled dependencies, and a lockfile that was telling the truth in a dialect the CI's npm didn't speak.
What's your app's cheapest feature that looked expensive — the one where the architecture had quietly already paid for it?
Top comments (0)