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>
231 lines
9.0 KiB
Dart
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);
|
|
});
|
|
}
|