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:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -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);
});
}