Skip to content
Mustafa0u0Public

About

A shopping app in Flutter with a tested domain underneath it — integer money, immutable basket, filters that compare the price actually charged, and real products with real photographs

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Bazaar

CI

A shopping app in Flutter — twenty-eight screens over a domain that was written and tested before any of them existed.

The shop front, running, with real products and photographs A product page The last step of checkout

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.

Where the products and photographs come from

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.

Money is never a double

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.

The basket

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.

Filters compare the price actually charged

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.

Search forgives typing

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.

Checkout, and what is deliberately not stored

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.

When the connection goes

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.

How it is laid out

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.

Two things that were fixed rather than left

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.

Running it

flutter run

Tests

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

About

A shopping app in Flutter with a tested domain underneath it — integer money, immutable basket, filters that compare the price actually charged, and real products with real photographs

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages