A shopping app in Flutter — twenty-eight screens over a domain that was written and tested before any of them existed.
The first screenshot is the app actually running: those are real products, real prices and real photographs, fetched live. The others are rendered by the golden tests, offline, which is why their images are placeholders — that is also what a shopper on a slow train sees, so it is worth seeing.
They are fetched at run time from a catalogue service that serves its own images and grants the right to display them. Nothing is bundled. Photographs of goods are somebody else's copyrighted work, and a code repository is not the place to keep a folder of them.
No key, no account, no sign-up — it works on first launch.
Prices arrive from the network as doubles, because that is what JSON has, and are converted once at the boundary. Rounded, not truncated: some encoders write 19.99 as 19.989999999999998, and truncating leaves the shop a cent light on every sale.
Discounts round once, on a total, never per line. A test shows the two disagree by a cent on three lines — and the per-line answer is wrong in the shop's favour every time, which is the half of the error a customer notices.
Immutable: every change returns a new basket, so a screen can never be looking at a list that changed underneath it.
Quantities are capped at the shelf inside the basket, not only in the stepper — the stepper is not the only way a quantity is set, and restoring a saved basket goes through the same door.
Delivery is free at the threshold, not just above it. An empty basket is not charged to deliver nothing. Tax is on the goods and not on the delivery, and the total is asserted to equal the sum of its parts.
Checkout is blocked while anything in the basket has sold out. Finding that out after payment is the difference between an apology and a refund.
A shopper filtering under twenty means the number on the label, so filters compare the discounted price, not the list price.
"Clear filters" keeps the sort order. Asking for cheapest first and getting relevance back is a small betrayal, and the filter badge does not count the sort either — a sort is always set to something, so counting it would leave the badge permanently showing one.
Ties are broken alphabetically in both directions, so two products at the same price keep a settled order however the sort is pointed. That is why the tests do not assert that high-to-low is an exact reversal of low-to-high: it should not be.
Half a word is enough, word order does not matter, and one mistyped letter is forgiven — including two letters swapped, which is two edits to a plain distance and one slip of the fingers to the person who did it. Below four characters the correction is off, because short words turn into other words.
Every word typed has to be answered by the product, so typing more narrows the results instead of widening them.
Four numbered steps, because an unmarked run of forms is where baskets are abandoned. The confirmation replaces the whole checkout stack, so backing out of it cannot place the order twice, and placing is guarded as well as disabled — a double tap can land two frames before a rebuild.
A card number is validated with Luhn, which catches a mistyped digit and a transposition before anything is sent anywhere. Then it is discarded. Only the last four digits and the brand are kept: enough to tell two cards apart on a list, and useless to anybody else.
No payment is taken and nothing leaves the device. Card numbers are checked in memory and dropped; expiry is valid through the end of the month it names, because rejecting a card on the first of its expiry month turns away a live one.
The forgotten-password screen does not confirm whether an address is registered. An app that says "no such account" tells anybody who asks which addresses exist.
The shelves stay as they were, with a notice above them. A shop that empties itself when the network drops looks sold out, which is worse than looking a little out of date. The categories fall back to the ones the cached products already name, because the categories are a view of the products.
Offline and server faults are told apart: a retry button appears for the failures worth retrying and not for the others.
lib/
app/ theme, typed routes, tab shell
core/ money, result and failure types
data/ models, sources, repository
domain/ cart, browsing, search, validation
state/ one ChangeNotifier, handed down an InheritedNotifier
features/ auth · catalog · cart · checkout · orders · account
shared/ widgets used by more than one feature
Routes are typed helpers rather than string names sprinkled through widgets: a route that needs a product cannot be reached without one, and a typo fails to compile instead of at run time.
State is a ChangeNotifier under an InheritedNotifier. For an app this size a
state library would be a dependency to install, a syntax to learn and a layer to
debug through, in exchange for what forty lines already do.
RadioListTile.groupValue is deprecated in this SDK. The four places using it
were moved to RadioGroup rather than left warning.
The data layer originally used dart:io's HttpClient, which does not exist in
a browser — the web build compiled and then died on the first request. It now
uses package:http, so the same code runs on mobile, desktop and web, and the
response is decoded as UTF-8 explicitly because response.body guesses from the
content type and falls back to Latin-1, which turns an accented brand name into
mojibake.
flutter run
flutter test
89 tests, none of which need a network: the source is exercised against a real
HttpServer on a loopback port, and the repository against sources that fail
the way a lost connection does.
Screenshots are golden tests, tagged and skipped by default — they render the images above rather than guarding against regressions, and a golden rendered on one machine will not match a rerun on another.
flutter test --tags screenshot --run-skipped --update-goldens


