Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,62 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/route_timeline.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||||
|
||||
/// The call button and the tick box on a Home stop row must sit on the same two
|
||||
/// columns for every stop in the route.
|
||||
/// A control a rider reaches for without looking has to be in the same place on
|
||||
/// every row of the route.
|
||||
///
|
||||
/// They did not. Both were *conditional* children of the meta row —
|
||||
/// `if (phone.isNotEmpty)` and `if (pending && onToggleSelect != null)` — so
|
||||
/// whenever one was missing the other slid across to take its place. A stop with
|
||||
/// no phone number pushed its tick box a tap-target to the right; an
|
||||
/// already-accepted stop pushed its call button the same way. Down a route of
|
||||
/// mixed states the two controls zig-zagged, and a rider reaching for "the tick"
|
||||
/// had to locate it again on every row.
|
||||
/// ── What this used to measure, and why it moved ──
|
||||
///
|
||||
/// The call button and the tick box on a Home stop *card* were conditional
|
||||
/// children of one meta row — `if (phone.isNotEmpty)` and `if (pending &&
|
||||
/// onToggleSelect != null)` — so whenever one was missing the other slid across
|
||||
/// to take its place. A stop with no phone number pushed its tick a tap-target
|
||||
/// to the right; an already-decided stop pushed its call button the same way.
|
||||
/// Down a route of mixed states the two zig-zagged, and a rider reaching for
|
||||
/// "the tick" had to locate it again on every row.
|
||||
///
|
||||
/// Both of those controls are gone with the cards. The tick *is* the row now,
|
||||
/// and the phone moved into the detail sheet with the rest of the progressive
|
||||
/// disclosure. So the principle is measured where it now lives: the timeline's
|
||||
/// spine. Every node — a place, an order, whatever state it is in — sits on one
|
||||
/// vertical axis, and every row's content starts on one left edge. That is a
|
||||
/// stronger version of the same contract, because it holds across two kinds of
|
||||
/// row rather than one, and it is what makes a run scannable in a glance.
|
||||
///
|
||||
/// This is the kind of thing that is obvious in a screenshot and invisible in
|
||||
/// code review, so it is measured: the x-positions are compared across a trip
|
||||
/// deliberately built to hit every combination.
|
||||
/// code review, so it is measured against a route deliberately built to mix
|
||||
/// every state Home still lists.
|
||||
void main() {
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Map<String, dynamic> stop(
|
||||
String id, {
|
||||
required String type,
|
||||
String? phone,
|
||||
}) =>
|
||||
{
|
||||
'orderid': id,
|
||||
'type': type,
|
||||
'pickupcustomer': 'Sri Balaji Stores',
|
||||
'pickupaddress': '19, Thadagam Road, Coimbatore',
|
||||
if (phone != null) 'pickupcontactno': phone,
|
||||
'Quantity': '2',
|
||||
'expected_pickup_time': DateTime.now().toIso8601String(),
|
||||
};
|
||||
String? kitchen,
|
||||
}) => {
|
||||
'orderid': id,
|
||||
'type': type,
|
||||
'pickupcustomer': 'Sri Balaji Stores',
|
||||
'pickupaddress': '19, Thadagam Road, Coimbatore',
|
||||
if (phone != null) 'pickupcontactno': phone,
|
||||
if (kitchen != null) 'kitchenid': kitchen,
|
||||
if (kitchen != null) 'kitchenname': kitchen,
|
||||
'Quantity': '2',
|
||||
'expected_pickup_time': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
/// A route covering every trailing-slot combination.
|
||||
///
|
||||
/// The states are the three Home still lists — pending, rejected, skipped.
|
||||
/// Accepted stops used to fill the "no tick box" cases here, but they leave
|
||||
/// Home for Bookings now, so a decided-but-still-listed state does the job:
|
||||
/// The states are the three Home still lists — pending, rejected, skipped:
|
||||
///
|
||||
/// 1 pending + phone → both controls
|
||||
/// 2 pending, no phone → tick only
|
||||
/// 3 rejected + phone → call only (no tick: it is already decided)
|
||||
/// 4 skipped, no phone → neither
|
||||
Future<void> pumpRoute(WidgetTester tester) async {
|
||||
/// 1 pending + phone → tick, and a way into the sheet
|
||||
/// 2 pending, no phone → the same, with nothing to call
|
||||
/// 3 rejected + phone → no tick; it is already decided
|
||||
/// 4 skipped, no phone → not listed at all
|
||||
Future<void> pumpRoute(WidgetTester tester, {String? kitchen}) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final trip = Trip.fromStops(id: 'mixed', stops: [
|
||||
stop('A', type: 'pickup', phone: '9876543210'),
|
||||
stop('B', type: 'delivery'),
|
||||
{...stop('C', type: 'combined', phone: '9876500000')},
|
||||
{...stop('D', type: 'pickup'), 'orderstatus': 'skipped'},
|
||||
]);
|
||||
final trip = Trip.fromStops(
|
||||
id: 'mixed',
|
||||
stops: [
|
||||
stop('A', type: 'pickup', phone: '9876543210', kitchen: kitchen),
|
||||
stop('B', type: 'delivery', kitchen: kitchen),
|
||||
stop('C', type: 'combined', phone: '9876500000', kitchen: kitchen),
|
||||
{
|
||||
...stop('D', type: 'pickup', kitchen: kitchen),
|
||||
'orderstatus': 'skipped',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
@@ -69,6 +90,8 @@ void main() {
|
||||
rejectedIds: const {'C'},
|
||||
onToggleSelect: (_) {},
|
||||
onCallStop: (_) {},
|
||||
onStopTap: (_) {},
|
||||
onUnrejectStop: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -78,100 +101,130 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('every call button shares one column', (tester) async {
|
||||
testWidgets('every node sits on one vertical axis', (tester) async {
|
||||
await pumpRoute(tester);
|
||||
|
||||
final calls = tester
|
||||
.widgetList<Widget>(find.byIcon(Icons.call_rounded))
|
||||
.toList();
|
||||
expect(calls.length, 2, reason: 'two of the four stops carry a phone');
|
||||
final nodes = find.byType(PickupTimelineGroup);
|
||||
expect(nodes.evaluate().length, greaterThan(1), reason: 'a mixed route');
|
||||
|
||||
final rects = find
|
||||
.byIcon(Icons.call_rounded)
|
||||
.evaluate()
|
||||
.map((e) => (e.renderObject! as RenderBox).localToGlobal(Offset.zero).dx)
|
||||
.toList();
|
||||
|
||||
expect(rects.first, closeTo(rects.last, 0.01),
|
||||
reason: 'the call buttons are at x=$rects — they must share a column '
|
||||
'even though one stop is pending and the other rejected');
|
||||
// The spine is a fixed-width rail with the node centred in it, so this
|
||||
// holds by construction — which is exactly why it is worth pinning. The
|
||||
// moment a state draws a wider node or a row grows a leading control, the
|
||||
// rail bends and the run stops being scannable.
|
||||
final centres = <double>[
|
||||
for (var i = 0; i < nodes.evaluate().length; i++)
|
||||
tester.getRect(nodes.at(i)).left,
|
||||
];
|
||||
for (final c in centres) {
|
||||
expect(c, closeTo(centres.first, 0.5));
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the call column does not move when the tick box is absent',
|
||||
(tester) async {
|
||||
testWidgets('the rail does not move when a row changes state', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRoute(tester);
|
||||
|
||||
// Stop A is pending and has a phone, so it shows BOTH controls.
|
||||
// Stop C is rejected and has a phone, so it shows the call button ALONE.
|
||||
// Before the fix, C's call button sat one tap-target further right.
|
||||
final callXs = find
|
||||
.byIcon(Icons.call_rounded)
|
||||
.evaluate()
|
||||
.map((e) => (e.renderObject! as RenderBox).localToGlobal(Offset.zero).dx)
|
||||
.toSet();
|
||||
// Two of the four are listed now. The skipped one has always been
|
||||
// Activity's row; the **rejected** one joined it — a declined stop leaves
|
||||
// Home the moment it is declined rather than sitting struck through with an
|
||||
// UNDO beside it. What is left on the route is the undecided work.
|
||||
final names = find.text('Sri Balaji Stores');
|
||||
expect(names.evaluate().length, 2);
|
||||
|
||||
expect(callXs.length, 1,
|
||||
reason: 'a missing tick box must not pull the call button across');
|
||||
final lefts = <double>[
|
||||
for (var i = 0; i < 2; i++) tester.getRect(names.at(i)).left,
|
||||
];
|
||||
for (final l in lefts) {
|
||||
expect(
|
||||
l,
|
||||
closeTo(lefts.first, 0.5),
|
||||
reason: 'a rejected row must not indent differently from a pending one',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the card and the rail each get their side gap', (tester) async {
|
||||
await pumpRoute(tester);
|
||||
testWidgets('every order in a place shares one axis', (tester) async {
|
||||
// ── What this used to say, and why it changed ──
|
||||
//
|
||||
// It pinned the *place* node and the *order* nodes to one axis, because a
|
||||
// spine ran from the header down through the steps and a stepped rail would
|
||||
// have bent it.
|
||||
//
|
||||
// Two things removed that line. The place and its orders are grouped by a
|
||||
// card now rather than by a thread, so the header draws no connector below
|
||||
// it; and the orders sit in an inset panel *inside* that card, which is what
|
||||
// makes them read as its contents. A panel that ignored its own container's
|
||||
// inset to keep a nonexistent line straight would just look broken.
|
||||
//
|
||||
// What still matters — and what actually makes a run scannable — is that
|
||||
// every step in a place shares one left edge, whatever state it is in. That
|
||||
// is what is pinned here, plus the panel's own inset, so the nesting cannot
|
||||
// drift a point at a time.
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
await pumpRoute(tester, kitchen: 'Vidhya Kitchen');
|
||||
|
||||
// The rail node is the numbered circle; the card is the fill beside it.
|
||||
final railLeft = tester.getRect(find.text('1')).left;
|
||||
expect(railLeft, greaterThan(12),
|
||||
reason: 'the rail should not run into the page gutter');
|
||||
final group = tester.getRect(find.byType(PickupTimelineGroup).first);
|
||||
final steps = find.byType(PickupTimelineStep);
|
||||
expect(steps.evaluate(), isNotEmpty, reason: 'the group is open');
|
||||
|
||||
// Nothing is clipped or overflowing at the other edge either.
|
||||
expect(tester.takeException(), isNull);
|
||||
final lefts = <double>[
|
||||
for (var i = 0; i < steps.evaluate().length; i++)
|
||||
tester.getRect(steps.at(i)).left,
|
||||
];
|
||||
for (final l in lefts) {
|
||||
expect(l, closeTo(lefts.first, 0.5), reason: 'one axis for every order');
|
||||
}
|
||||
|
||||
expect(
|
||||
lefts.first - group.left,
|
||||
greaterThan(0),
|
||||
reason: 'the orders sit inside the card, not flush against its edge',
|
||||
);
|
||||
expect(
|
||||
lefts.first - group.left,
|
||||
// ≤ 22: the panel inset plus the well's own padding. The well returned
|
||||
// when the route moved onto a white MilerPanel — open orders were
|
||||
// white-on-white — so the deliberate indent is two small steps now, and
|
||||
// this bound is what keeps it from becoming three.
|
||||
lessThanOrEqualTo(22),
|
||||
reason:
|
||||
'measured ${lefts.first - group.left} — a nested well, not a second '
|
||||
'indent level growing a point at a time',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the two controls are the same size', (tester) async {
|
||||
await pumpRoute(tester);
|
||||
testWidgets('the phone is not on an order row', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.meals);
|
||||
await pumpRoute(tester, kitchen: 'Vidhya Kitchen');
|
||||
|
||||
final call = tester.getSize(find.byIcon(Icons.call_rounded).first);
|
||||
expect(call.width, call.height, reason: 'the call button is square');
|
||||
// ── Re-scoped, and the contract is unchanged ──
|
||||
//
|
||||
// This asserted no call control anywhere on the route. That was right while
|
||||
// there was nowhere sensible to put one — but ringing a kitchen is an action
|
||||
// on the *place*, and it now lives on the pickup card beside Navigate, where
|
||||
// the rider can reach it without opening a list of five customers he is not
|
||||
// looking for.
|
||||
//
|
||||
// What the test was really protecting is the thing it is still protecting: a
|
||||
// **step** carries who and which bag, and nothing else. Five call buttons
|
||||
// dialling one counter is the meta row coming back one control at a time.
|
||||
for (
|
||||
var i = 0;
|
||||
i < find.byType(PickupTimelineStep).evaluate().length;
|
||||
i++
|
||||
) {
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(PickupTimelineStep).at(i),
|
||||
matching: find.byIcon(LucideIcons.phone),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'step $i grew a call control',
|
||||
);
|
||||
}
|
||||
|
||||
// Both controls render inside equal-width reserved slots.
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('every duration sits on one right-hand column', (tester) async {
|
||||
await pumpRoute(tester);
|
||||
|
||||
// "~5 min" is materially narrower than "~10 min". Left-aligned, that put the
|
||||
// duration in a different place on every card and dragged the call button
|
||||
// and tick box along with it. Each now sits in a fixed-width slot,
|
||||
// right-aligned, so all four share a trailing edge.
|
||||
final rights = find
|
||||
.textContaining('~')
|
||||
.evaluate()
|
||||
.map((e) {
|
||||
final box = e.renderObject! as RenderBox;
|
||||
return box.localToGlobal(Offset.zero).dx + box.size.width;
|
||||
})
|
||||
.toSet();
|
||||
|
||||
expect(rights.length, 1,
|
||||
reason: 'durations end at $rights — they must share one column');
|
||||
});
|
||||
|
||||
testWidgets('the three trailing elements keep a constant spacing',
|
||||
(tester) async {
|
||||
await pumpRoute(tester);
|
||||
|
||||
double leftOf(Finder f) =>
|
||||
(f.evaluate().first.renderObject! as RenderBox)
|
||||
.localToGlobal(Offset.zero)
|
||||
.dx;
|
||||
|
||||
// Duration, then call, then tick — in that order, left to right, and the
|
||||
// gaps between them are identical on every card because all three are
|
||||
// fixed-width slots rather than content-sized children.
|
||||
final duration = leftOf(find.textContaining('~'));
|
||||
final call = leftOf(find.byIcon(Icons.call_rounded));
|
||||
expect(duration, lessThan(call),
|
||||
reason: 'the duration reads before the controls it belongs to');
|
||||
expect(tester.takeException(), isNull);
|
||||
// And exactly one on the place that owns it.
|
||||
expect(find.byIcon(LucideIcons.phone), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user