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

@@ -7,7 +7,6 @@ import 'package:doormile_cx/main.dart';
import 'package:doormile_cx/state/app_state.dart';
import 'package:doormile_cx/ui/widgets/buttons.dart';
import 'package:doormile_cx/ui/widgets/map_panel.dart';
import 'package:doormile_cx/ui/widgets/chrome.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -86,7 +85,7 @@ Future<void> serverAdvance(
/// and the form is what the answer leads to, so every booking test is
/// [openSend] followed by [pickCity].
Future<void> openSend(WidgetTester tester) async {
await tester.tap(find.text('BOOK'));
await tester.tap(find.text('BOOK NOW'));
await settle(tester);
}
@@ -99,6 +98,15 @@ Future<void> openSend(WidgetTester tester) async {
/// The destination is reached by searching rather than by opening its state:
/// search cuts across every state from the first step, and it is the path a
/// customer who already knows where they are sending actually takes.
/// A finder restricted to the open bottom sheet.
///
/// Home's live card heads with the district alone — "Chennai", not
/// "Chennai, Tamil Nadu" — so a bare `find.text('Chennai')` matches the screen
/// behind the scrim as well as the row in the sheet. Every assertion about
/// what a sheet is offering goes through this.
Finder inSheet(Finder matching) =>
find.descendant(of: find.byType(BottomSheet), matching: matching);
Future<void> pickCity(
WidgetTester tester,
String name, {
@@ -106,11 +114,20 @@ Future<void> pickCity(
}) async {
await tester.enterText(find.byType(TextField).first, name);
await settle(tester, 250);
// A `Text`, explicitly: `find.text` also matches `EditableText`, so once the
// city's name is in the search box the plain finder returns two widgets and
// `.first` is the box itself. Tapping that only refocuses it.
// A `Text` inside the sheet, explicitly.
//
// `find.text` also matches `EditableText`, so once the city's name is in the
// search box the plain finder returns the box as well — and Home, behind the
// scrim, now heads its live card with the district alone, so an unscoped
// finder can land on a widget the sheet is covering. Scoping to the modal
// route fixes both.
await tester.tap(
find.byWidgetPredicate((w) => w is Text && w.data == name).first,
find
.descendant(
of: find.byType(BottomSheet),
matching: find.byWidgetPredicate((w) => w is Text && w.data == name),
)
.first,
);
await settle(tester);
@@ -137,7 +154,7 @@ Future<void> pickCity(
Future<void> openPickupEditor(WidgetTester tester) async {
await openSend(tester);
await pickCity(tester, 'Chennai');
await tester.tap(find.text('FROM'));
await tester.tap(find.text('ORIGIN'));
await settle(tester);
}
@@ -192,17 +209,20 @@ void main() {
await settle(tester);
// All four digits arrived, so it verified and moved on.
expect(find.text('BOOK'), findsOneWidget);
expect(find.text('BOOK NOW'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('signs in and lands on Home', (tester) async {
await signIn(tester);
expect(find.text('Send a parcel'), findsOneWidget);
expect(find.text('BOOK'), findsOneWidget);
// The seeded active booking surfaces on Home.
expect(find.text('Miler assigned'), findsOneWidget);
// Home greets by name, carries the sphere, and ends on the live card.
expect(find.text('Hello, Joe'), findsOneWidget);
expect(find.text('BOOK NOW'), findsOneWidget);
// The seeded active booking surfaces on Home. Its state is the uppercase
// micro-label beside the destination, in the chip vocabulary — "Assigned",
// not the rail's "Miler assigned".
expect(find.text('ASSIGNED'), findsOneWidget);
await drainToasts(tester);
});
@@ -233,7 +253,7 @@ void main() {
await tester.tap(find.text('Use this address'));
await settle(tester);
// Back on the send screen: its title is the route, not a heading.
expect(find.text('TO'), findsOneWidget);
expect(find.text('DESTINATION'), findsOneWidget);
expect(find.text(corrected), findsOneWidget);
await drainToasts(tester);
});
@@ -282,7 +302,7 @@ void main() {
// …and that one point is what the booking carries forward.
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(find.text('TO'), findsOneWidget);
expect(find.text('DESTINATION'), findsOneWidget);
expect(state.draftPickup!.lat, searched.lat);
expect(state.draftPickup!.lng, searched.lng);
expect(state.draftPickup!.title, 'Brookefields Mall');
@@ -398,8 +418,8 @@ void main() {
// Everything else the booking needs is on one screen, and the route to
// the top of it is two rows with no labels but FROM and TO.
expect(find.text('FROM'), findsOneWidget);
expect(find.text('TO'), findsOneWidget);
expect(find.text('ORIGIN'), findsOneWidget);
expect(find.text('DESTINATION'), findsOneWidget);
// Nothing is asked for here at all any more: no address fields, and no
// disclosure hiding three more. The route, the packages and the button.
expect(find.byType(TextField), findsNothing);
@@ -408,9 +428,9 @@ void main() {
// The customer answered the window sheet on the way in, so the booking
// already carries a slot they chose.
expect(state.draftSlotId, isNotNull);
expect(find.text('Book pickup'), findsOneWidget);
expect(find.text('Confirm Booking'), findsOneWidget);
await tester.tap(find.text('Book pickup'));
await tester.tap(find.text('Confirm Booking'));
await settle(tester);
expect(find.text('Pickup booked'), findsOneWidget);
@@ -441,7 +461,7 @@ void main() {
expect(state.draftContactPhone, isNull);
// Changed, it is the number the Miler will ring at the door.
await tester.tap(find.text('FROM'));
await tester.tap(find.text('ORIGIN'));
await settle(tester);
await tester.enterText(find.byType(TextField).first, '9003144518');
await settle(tester, 200);
@@ -458,12 +478,12 @@ void main() {
await openSend(tester);
await pickCity(tester, 'Coimbatore');
expect(find.text('1 package'), findsOneWidget);
expect(find.text('1 parcel'), findsOneWidget);
await tester.tap(find.byIcon(LucideIcons.plus));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.plus));
await tester.pump();
expect(find.text('3 packages'), findsOneWidget);
expect(find.text('3 parcels'), findsOneWidget);
expect(state.draftTotalPackages, 3);
// Push past the cap on the state; the clamp is the model's, not the UI's.
@@ -471,7 +491,7 @@ void main() {
await tester.pump();
expect(state.draftTotalPackages, state.limits.maxPackages);
await tester.tap(find.text('Book pickup'));
await tester.tap(find.text('Confirm Booking'));
await settle(tester);
expect(state.trackedBooking!.totalPackages, state.limits.maxPackages);
await drainToasts(tester);
@@ -485,9 +505,10 @@ void main() {
await pickCity(tester, 'Chennai');
final first = state.draftSlotId;
// The window lives in the footer now, beside the price it is quoted
// against, and the whole left column opens the sheet.
await tester.tap(find.text('PICKUP'));
// The window is a row on the review list, under the route. It carries no
// label — the clock glyph and the time are the whole row — so it is found
// by the window itself.
await tester.tap(find.textContaining('2:00 – 4:00 PM').first);
await settle(tester);
expect(find.text('Pickup window'), findsOneWidget);
@@ -508,26 +529,26 @@ void main() {
await openSend(tester);
// Step one is the states, not sixty district names.
expect(find.text('Tamil Nadu'), findsOneWidget);
expect(find.text('Kerala'), findsOneWidget);
expect(find.text('Chennai'), findsNothing);
expect(inSheet(find.text('Tamil Nadu')), findsOneWidget);
expect(inSheet(find.text('Kerala')), findsOneWidget);
expect(inSheet(find.text('Chennai')), findsNothing);
// Step two is that state's districts.
await tester.tap(find.text('Tamil Nadu'));
await settle(tester);
expect(find.text('Chennai'), findsOneWidget);
expect(find.text('Coimbatore'), findsWidgets);
expect(inSheet(find.text('Chennai')), findsOneWidget);
expect(inSheet(find.text('Coimbatore')), findsWidgets);
// Districts that are not open are never offered, and neither is a state
// whose every district is closed.
expect(find.text('Madurai'), findsNothing);
expect(find.text('Puducherry'), findsNothing);
expect(inSheet(find.text('Madurai')), findsNothing);
expect(inSheet(find.text('Puducherry')), findsNothing);
// Back out, then let search cut across states: Ernakulam is in Kerala and
// the customer should not have to know that to find it.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester);
expect(find.text('Chennai'), findsNothing);
expect(inSheet(find.text('Chennai')), findsNothing);
await pickCity(tester, 'Ernakulam');
expect(find.textContaining('Ernakulam'), findsWidgets);
@@ -538,19 +559,21 @@ void main() {
final state = AppState();
await signIn(tester, state: state);
await tester.tap(find.text('Orders'));
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
// Before collection: one row, owned by the pickup.
expect(find.text('DM-482913'), findsOneWidget);
// Before collection: one row, owned by the pickup. The reference shares a
// line with the date and the contents now, so it is a span inside a
// paragraph rather than a Text of its own.
expect(find.textContaining('DM-482913'), findsOneWidget);
// After collection: the seeded multi-destination pickup is in transit, so
// each destination stands on its own row.
final booking = state.byReference('DM-471200')!;
expect(booking.hasOrders, isTrue);
expect(find.text('Pickup DM-471200'), findsNothing);
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget);
expect(find.text('Ernakulam'), findsOneWidget);
expect(find.text('Coimbatore'), findsOneWidget);
// Each row is identified by its OWN tracking number, not by the visit that
// produced it. "Collected on DM-471200" used to run under both rows; it
@@ -558,7 +581,7 @@ void main() {
// them, so it moved to the order's own screen.
expect(find.text('Collected on DM-471200'), findsNothing);
for (final group in booking.destinations) {
expect(find.text(group.trackingId!), findsOneWidget);
expect(find.textContaining(group.trackingId!), findsOneWidget);
}
// Independently tracked.
expect(
@@ -566,10 +589,12 @@ void main() {
booking.destinations.length,
);
// Opening one follows that order, not the whole visit.
await tester.tap(find.text('Ernakulam, Kerala'));
// Opening one follows that order, not the whole visit: the head card's
// route ends at that destination, and the reference is its own tracking id.
await tester.tap(find.text('Ernakulam'));
await settle(tester);
expect(find.text('To Ernakulam, Kerala'), findsOneWidget);
expect(find.text('Ernakulam, Kerala'), findsWidgets);
expect(find.text('YOUR PACKAGES'), findsOneWidget);
expect(find.text('YOUR PACKAGES'), findsOneWidget);
await drainToasts(tester);
});
@@ -579,7 +604,10 @@ void main() {
final state = AppState();
await signIn(tester, state: state);
await tester.tap(find.text('Miler assigned'));
// The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester);
// The whole shipment is on screen the moment tracking opens — five rows
@@ -613,12 +641,17 @@ void main() {
final state = AppState();
await signIn(tester, state: state);
await tester.tap(find.text('Miler assigned'));
// The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester);
// The live card, not an eyebrow: a pickup in flight is the screen's one
// crimson surface.
expect(find.text('LIVE'), findsOneWidget);
// The head card names the state in a pill and draws the route as a rail.
// It used to be a filled crimson card headed "LIVE" — crimson is the
// action colour, and tracking is a screen you read.
expect(find.text('Miler assigned'), findsWidgets);
expect(find.text('12 Nehru Street'), findsWidgets);
expect(find.text('Cancel this pickup'), findsOneWidget);
// No per-package breakdown before collection.
expect(find.text('YOUR PACKAGES'), findsNothing);
@@ -638,7 +671,10 @@ void main() {
final state = AppState();
await signIn(tester, state: state);
await tester.tap(find.text('Miler assigned'));
// The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester);
await tester.tap(find.text('Cancel this pickup'));
@@ -650,15 +686,19 @@ void main() {
await tester.tap(find.text('Yes, cancel pickup'));
await settle(tester);
expect(find.text('Booking cancelled'), findsOneWidget);
// The head card carries the state in its pill now, not as a headline.
expect(find.textContaining('Cancelled'), findsWidgets);
final booking = state.byReference('DM-482913')!;
expect(booking.status, BookingStatus.cancelled);
await drainToasts(tester);
});
testWidgets('shows the empty service-area state', (tester) async {
await signIn(tester);
// Set before signing in. Home's quick-dispatch row calls `loadCities()`
// as soon as it builds, which warms `statesCache` — good for the sheet,
// which now opens instantly, and fatal to a flag flipped afterwards.
api.flags.emptyServiceAreas = true;
await signIn(tester);
await openSend(tester);
@@ -688,12 +728,12 @@ void main() {
testWidgets('a delivered order opens its receipt', (tester) async {
await signIn(tester);
await tester.tap(find.text('Orders'));
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
await tester.tap(find.text('Completed'));
await tester.tap(find.textContaining('Past'));
await settle(tester);
await tester.tap(find.text('Salem, Tamil Nadu'));
await tester.tap(find.text('Salem'));
await settle(tester);
expect(find.text('Delivered'), findsWidgets);
@@ -717,12 +757,12 @@ void main() {
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester); // login + OTP
expect(find.text('BOOK'), findsOneWidget); // home
expect(find.text('BOOK NOW'), findsOneWidget); // home
await openSend(tester); // the whole booking, one screen
await pickCity(tester, 'Chennai');
await tester.tap(find.text('Book pickup'));
await tester.tap(find.text('Confirm Booking'));
await settle(tester); // confirmation
await tester.tap(find.text('Track pickup'));
await settle(tester); // tracking
@@ -760,11 +800,15 @@ void main() {
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester);
await tester.tap(find.text('Orders'));
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
await tester.tap(find.text('Completed'));
// At 1.8x the three tabs are wider than the screen, so the second one has
// to be scrolled to before it can be tapped.
await tester.ensureVisible(find.textContaining('Past'));
await tester.pump();
await tester.tap(find.textContaining('Past'));
await settle(tester);
await tester.tap(find.text('Salem, Tamil Nadu'));
await tester.tap(find.text('Salem'));
await settle(tester); // receipt
await drainToasts(tester);
@@ -774,22 +818,26 @@ void main() {
final state = AppState();
await signIn(tester, state: state);
await tester.tap(find.text('Orders'));
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
// Each tab carries its own count, inside the header's segmented control —
// and the figure is the rows that tab will actually show, which for a
// collected multi-destination pickup is one per destination.
// Each tab carries its own count — the figure is the rows that tab will
// actually show, which for a collected multi-destination pickup is one
// per destination. The count shares a paragraph with the label, so it is
// a span rather than a Text of its own.
expect(
find.descendant(
of: find.byType(DmSegmented),
matching: find.text('${state.entriesOf(BookingStatus.active).length}'),
find.textContaining(
'Active (${state.entriesOf(BookingStatus.active).length})',
),
findsOneWidget,
);
await tester.tap(find.text('Cancelled'));
// The tab row scrolls — three labels plus their counts do not fit a 390pt
// phone at large text sizes.
await tester.ensureVisible(find.textContaining('Cancelled').first);
await tester.pump();
await tester.tap(find.textContaining('Cancelled').first);
await settle(tester);
expect(find.text('Erode, Tamil Nadu'), findsOneWidget);
expect(find.text('Erode'), findsOneWidget);
await drainToasts(tester);
});
}