Redesign on the Stitch reference, in Plus Jakarta Sans

Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-24 11:08:25 +05:30
parent 06fa6b797a
commit 86b6af48c2
73 changed files with 3398 additions and 1722 deletions

View File

@@ -345,7 +345,25 @@ class LiveDoormileApi extends DoormileApi {
// This app does not have them: the pickup is a map pin, and the
// reverse geocoder returns an address, not a serviceable-area code. The
// coordinates say the same thing and the server already resolves them.
'pickup': {'latitude': ?pickup.lat, 'longitude': ?pickup.lng},
//
// ── `lat`/`lng`, and this one was quoting the wrong price ──
//
// Same field-name mismatch as `Place.toJson`, and on this endpoint it
// fails silently instead of erroring. With `latitude`/`longitude` the
// server cannot read the pin, measures no route, and answers a flat
// fallback band for every journey. Measured against production on
// 2026-09-23, same pickup, one request apart:
//
// latitude/longitude local ₹280–520 routeKm 0
// 427 km ₹280–520 routeKm 0
// lat/lng local ₹45–90 routeKm 0.2
// 427 km ₹150–280 routeKm 427.3
//
// So every fare this app has ever shown was the fallback: roughly six
// times the real price on a local run, and the same number quoted for
// a parcel crossing the state. A 422 tells you it is broken; this just
// lies.
'pickup': {'lat': ?pickup.lat, 'lng': ?pickup.lng},
'destinations': [
for (final d in destinations)
{

View File

@@ -114,6 +114,23 @@ enum JourneyStage {
JourneyStage.outForDelivery => 'Out for delivery',
JourneyStage.delivered => 'Delivered',
};
/// The same state in one or two words, for a status chip.
///
/// [milestoneLabel] is a sentence fragment written to sit under a heading —
/// "Miler assigned", "Miler at your door". Set in a 9.5pt chip beside a
/// reference and a time it renders as "MILER ASSIG…", which is not a state.
String get chipLabel => switch (this) {
JourneyStage.booked => 'Booked',
JourneyStage.assigned => 'Assigned',
JourneyStage.onTheWay => 'On the way',
JourneyStage.arrived => 'At your door',
JourneyStage.pickedUp => 'Collected',
JourneyStage.orderCreated => 'Created',
JourneyStage.inTransit => 'In transit',
JourneyStage.outForDelivery => 'Out for delivery',
JourneyStage.delivered => 'Delivered',
};
}
/// What the customer sees on the timeline.
@@ -418,11 +435,31 @@ class Place {
lng: coordinate(json, 'lng'),
);
/// ── The coordinates are `lat`/`lng`, NOT `latitude`/`longitude` ──
///
/// The written contract says `latitude`/`longitude` and this sent them for
/// months. The server reads `lat`/`lng`, so it saw a pickup with no
/// coordinates at all, could not place it in a serviceable area, and
/// answered every single booking with **422 `unserviceable` — "We are not
/// collecting from that area yet"**. The app could not create a booking
/// against production; the error blamed the customer's address for a field
/// name.
///
/// Verified against production on 2026-09-23, one request apart: the same
/// pickup, the same destination, the same slot. `latitude`/`longitude` →
/// 422 unserviceable. `lat`/`lng` → 201 and booking DM-252803. It is also
/// what `GET /places/reverse-geocode` returns, which is where this Place
/// came from in the first place — the app was renaming the server's own
/// keys on the way back out.
///
/// Second one of these on this surface: `POST /auth/otp/verify` takes
/// `code` where the document says `otp`. Follow the server, not the
/// document.
Map<String, dynamic> toJson() => {
'title': title,
'sub': sub,
'latitude': ?lat,
'longitude': ?lng,
'lat': ?lat,
'lng': ?lng,
};
/// `POST /customer/bookings` ▸ `pickup`.
@@ -471,6 +508,11 @@ class Destination {
return coded.isEmpty ? '—' : coded.join(', ');
}
/// The district alone, for a headline. Falls back to the full [label] when
/// the backend sent only a state or only codes — a card headed "—" is worse
/// than a card headed with two words.
String get shortLabel => _clean(districtName) ?? _clean(districtCode) ?? label;
static String? _clean(String? v) {
final t = v?.trim();
return (t == null || t.isEmpty) ? null : t;