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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user