Files
doormile_milderapp/test/stop_row_alignment_test.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

231 lines
9.0 KiB
Dart

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';
/// A control a rider reaches for without looking has to be in the same place on
/// every row of the route.
///
/// ── 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 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,
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:
///
/// 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', 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(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: trip,
rejectedIds: const {'C'},
onToggleSelect: (_) {},
onCallStop: (_) {},
onStopTap: (_) {},
onUnrejectStop: (_) async {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('every node sits on one vertical axis', (tester) async {
await pumpRoute(tester);
final nodes = find.byType(PickupTimelineGroup);
expect(nodes.evaluate().length, greaterThan(1), reason: 'a mixed route');
// 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 rail does not move when a row changes state', (
tester,
) async {
await pumpRoute(tester);
// 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);
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('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');
final group = tester.getRect(find.byType(PickupTimelineGroup).first);
final steps = find.byType(PickupTimelineStep);
expect(steps.evaluate(), isNotEmpty, reason: 'the group is open');
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 phone is not on an order row', (tester) async {
ServiceProfile.setActive(ServiceProfile.meals);
await pumpRoute(tester, kitchen: 'Vidhya Kitchen');
// ── 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',
);
}
// And exactly one on the place that owns it.
expect(find.byIcon(LucideIcons.phone), findsOneWidget);
});
}