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:
410
test/home_structure_test.dart
Normal file
410
test/home_structure_test.dart
Normal file
@@ -0,0 +1,410 @@
|
||||
import 'package:flutter/material.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';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// CONTENT LEADS, CONTAINERS RECEDE
|
||||
///
|
||||
/// Home's grouping unit is a **pickup place**, and for three iterations it was
|
||||
/// drawn as a box. First a card per order — fifteen boxes on a route. Then a
|
||||
/// card per place, which was the right correction to the wrong object and
|
||||
/// still the wrong object: a route already says that two things belong
|
||||
/// together, with a spine, a node and an indent, so the rectangle said it a
|
||||
/// second time in a louder voice. The orders then needed a *tinted* rectangle
|
||||
/// inside that one to separate themselves from their own container, which is
|
||||
/// the point at which the furniture is arguing with itself.
|
||||
///
|
||||
/// These pin the third answer: no surface at all. The group is the spine plus
|
||||
/// whitespace plus type, the orders sit on Home's own ground, and the three
|
||||
/// things a rider does to a kitchen have three separate targets.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Map<String, dynamic> meal(String id, String who, {int step = 1}) => {
|
||||
'orderid': id,
|
||||
'type': 'delivery',
|
||||
'step': step,
|
||||
'pickupcustomer': who,
|
||||
'kitchenid': 'K1',
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'sourcename': 'Vidhya Kitchen',
|
||||
'pickupcontactno': '9787698259',
|
||||
'pickupaddress': '4 Cross, RS Puram, Coimbatore',
|
||||
'dropaddress': '12 SNS Colony, Peelamedu, Coimbatore 641004',
|
||||
'Quantity': '1',
|
||||
};
|
||||
|
||||
Future<void> pumpHome(
|
||||
WidgetTester tester, {
|
||||
Set<String> accepted = const {},
|
||||
Set<String> selected = const {},
|
||||
void Function(String)? onToggleSelect,
|
||||
double width = 390,
|
||||
double textScale = 1.0,
|
||||
}) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
tester.view.physicalSize = Size(width * 3, 2600 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final trip = Trip.fromStops(
|
||||
id: 'run',
|
||||
stops: [
|
||||
meal('1', 'Joe Mathew', step: 1),
|
||||
meal('2', 'Arun Prakash', step: 2),
|
||||
meal('3', 'Ravi Kumar', step: 3),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: TextScaler.linear(textScale)),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
backgroundColor: ColorConstants.daylightSurface,
|
||||
body: SingleChildScrollView(
|
||||
child: TripCard(
|
||||
trip: trip,
|
||||
acceptedIds: accepted,
|
||||
selectedIds: selected,
|
||||
onToggleSelect: onToggleSelect ?? (_) {},
|
||||
onSelectAll: (_) {},
|
||||
onStopTap: (_) {},
|
||||
onCallStop: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Makes sure the kitchen's orders are on screen.
|
||||
///
|
||||
/// The run's first place opens itself — a rider arriving at work should see
|
||||
/// the counter he is riding to, not a closed list — so this only taps when
|
||||
/// it is actually shut. Tapping unconditionally would *collapse* it, which
|
||||
/// is how a row assertion ends up looking like a broken row.
|
||||
Future<void> openKitchen(WidgetTester tester) async {
|
||||
if (find.text('Joe Mathew').evaluate().isEmpty) {
|
||||
await tester.tap(find.text('Vidhya Kitchen'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
group('the kitchen is not a box', () {
|
||||
testWidgets('no surface, border, shadow or radius groups a kitchen', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester);
|
||||
|
||||
// Every decoration inside the group, at any depth. A grouping rectangle
|
||||
// is exactly what this screen may not draw — see the header note.
|
||||
final decorations = tester
|
||||
.widgetList<Container>(
|
||||
find.descendant(
|
||||
of: find.byType(PickupTimelineGroup),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
)
|
||||
.map((c) => c.decoration)
|
||||
.whereType<BoxDecoration>();
|
||||
|
||||
// Circles are exempt: the sequence node and the tick are round marks on
|
||||
// the spine, which is the language doing the grouping rather than a
|
||||
// rectangle competing with it.
|
||||
final rectangles = decorations.where((d) => d.shape != BoxShape.circle);
|
||||
for (final d in rectangles) {
|
||||
expect(
|
||||
d.boxShadow,
|
||||
anyOf(isNull, isEmpty),
|
||||
reason: 'a shadow lifts a group off the page; the spine groups it',
|
||||
);
|
||||
expect(
|
||||
d.border,
|
||||
isNull,
|
||||
reason: 'an outline around a group is the card by another name',
|
||||
);
|
||||
}
|
||||
|
||||
// ── What a fill may be here, since the route moved onto a panel ──
|
||||
//
|
||||
// The original rule was "no filled rectangle at all": the orders sat on
|
||||
// a white page, so any panel behind them was a card by another name.
|
||||
// The route now stands on a white [MilerPanel], which re-created the
|
||||
// exact flatness that rule was written against — an open kitchen was
|
||||
// white-on-white. Two fills are now legitimate, and only these two:
|
||||
//
|
||||
// • exactly one canvas-toned well behind the open orders — the
|
||||
// ladder's single permitted inset, reading as the card opening
|
||||
// down to the page;
|
||||
// • small white tags (the bag labels) standing on that well.
|
||||
//
|
||||
// What stays banned is what the rule always meant: a white panel wide
|
||||
// enough to be a row's or the group's own card.
|
||||
final fillBoxes = find
|
||||
.descendant(
|
||||
of: find.byType(PickupTimelineGroup),
|
||||
matching: find.byType(DecoratedBox),
|
||||
)
|
||||
.evaluate()
|
||||
.where((e) {
|
||||
final d = (e.widget as DecoratedBox).decoration;
|
||||
return d is BoxDecoration &&
|
||||
d.shape != BoxShape.circle &&
|
||||
(d.color == ColorConstants.pureSurface ||
|
||||
d.color == ColorConstants.daylightSurface);
|
||||
})
|
||||
.toList();
|
||||
|
||||
final wells = fillBoxes
|
||||
.where(
|
||||
(e) =>
|
||||
((e.widget as DecoratedBox).decoration as BoxDecoration)
|
||||
.color ==
|
||||
ColorConstants.daylightSurface,
|
||||
)
|
||||
.toList();
|
||||
expect(
|
||||
wells.length,
|
||||
lessThanOrEqualTo(1),
|
||||
reason: 'one well behind the open orders, never a stack of them',
|
||||
);
|
||||
|
||||
for (final e in fillBoxes) {
|
||||
final d = (e.widget as DecoratedBox).decoration as BoxDecoration;
|
||||
if (d.color == ColorConstants.pureSurface) {
|
||||
final w = e.renderObject!.paintBounds.width;
|
||||
expect(
|
||||
w,
|
||||
lessThan(120),
|
||||
reason:
|
||||
'a white fill inside the group must be a small tag — at '
|
||||
'${w.toStringAsFixed(1)}pt wide it is a row panel, which is '
|
||||
'the card by another name',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('an order row carries no tile of its own', (tester) async {
|
||||
await pumpHome(tester, accepted: const {'1'}, selected: const {'1'});
|
||||
|
||||
// Selection and acceptance are carried by the node — shape and fill —
|
||||
// and by the name's weight. Painting whole rows made a list of five
|
||||
// steps read as five loose objects on open ground.
|
||||
final steps = find.byType(PickupTimelineStep);
|
||||
expect(steps, findsWidgets);
|
||||
|
||||
final tinted = tester
|
||||
.widgetList<AnimatedContainer>(
|
||||
find.descendant(
|
||||
of: steps,
|
||||
matching: find.byType(AnimatedContainer),
|
||||
),
|
||||
)
|
||||
.map((c) => c.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
// The node is a filled disc when selected — that is the channel
|
||||
// carrying the state, and it is deliberately not a rectangle.
|
||||
.where((d) => d.shape != BoxShape.circle)
|
||||
.where((d) => d.color != null && d.color != Colors.transparent);
|
||||
expect(tinted, isEmpty, reason: 'no wash across a row');
|
||||
});
|
||||
});
|
||||
|
||||
group('three targets, three jobs', () {
|
||||
testWidgets('the kitchen header only expands — it never selects', (
|
||||
tester,
|
||||
) async {
|
||||
final selected = <String>[];
|
||||
await pumpHome(tester, onToggleSelect: selected.add);
|
||||
|
||||
// The header is the identity block. Tapping it opens and closes the
|
||||
// place, and that is the whole of its contract: a gesture that also
|
||||
// ticked an order would mean the same object did two things depending
|
||||
// on data the rider cannot see.
|
||||
await tester.tap(find.text('Vidhya Kitchen'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, isEmpty, reason: 'disclosure is not selection');
|
||||
});
|
||||
|
||||
testWidgets('an order row selects, and does not open anything', (
|
||||
tester,
|
||||
) async {
|
||||
final selected = <String>[];
|
||||
final opened = <String>[];
|
||||
await pumpHome(tester, onToggleSelect: selected.add);
|
||||
await openKitchen(tester);
|
||||
|
||||
await tester.tap(find.text('Joe Mathew'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, ['1']);
|
||||
expect(opened, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('the bag block opens details, and does not select', (
|
||||
tester,
|
||||
) async {
|
||||
final selected = <String>[];
|
||||
await pumpHome(tester, onToggleSelect: selected.add);
|
||||
await openKitchen(tester);
|
||||
|
||||
// Siblings, never nested: two recognisers over the same pixels resolve
|
||||
// to neither, which is how the chevron was once dead for a whole build.
|
||||
await tester.tap(find.text('Bag 1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
selected,
|
||||
isEmpty,
|
||||
reason: 'the bag corner is the way in, not a second tick',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the metadata is metadata', () {
|
||||
testWidgets('Pending is neutral, not a warning', (tester) async {
|
||||
await pumpHome(tester);
|
||||
|
||||
// Amber is this app's exception hue. On a fresh morning every kitchen is
|
||||
// pending, so spending it here opens the whole screen in warning and
|
||||
// leaves nothing to say with when a stop genuinely goes wrong.
|
||||
final word = tester.widget<Text>(find.text('Pending'));
|
||||
expect(word.style!.color, ColorConstants.secondaryText);
|
||||
expect(word.style!.color, isNot(ColorConstants.warning));
|
||||
});
|
||||
|
||||
testWidgets('a partly accepted kitchen says so as a fraction', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester, accepted: const {'1'});
|
||||
|
||||
// Two of five accepted must never render the same word as five of five.
|
||||
expect(find.text('Accepted 1/3'), findsOneWidget);
|
||||
expect(find.text('Accepted'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the state wears no capsule', (tester) async {
|
||||
await pumpHome(tester);
|
||||
|
||||
// ── Only the boxes that hug the word ──
|
||||
//
|
||||
// This walked *every* `DecoratedBox` above the text and demanded none of
|
||||
// them be filled, which held only while Home had no filled surfaces at
|
||||
// all. The route now stands on a white `MilerPanel` — layer 1, the page
|
||||
// having a ground is the point — and that panel is an ancestor of every
|
||||
// word on the screen, so the test began failing on a surface it was
|
||||
// never about.
|
||||
//
|
||||
// A capsule is a box drawn *around a word*: it is the width of the word
|
||||
// plus its padding. A page surface is not. Filtering by width keeps the
|
||||
// original claim — the state is set, not stamped — and makes it immune
|
||||
// to whatever the page is standing on.
|
||||
final word = tester.getRect(find.text('Pending'));
|
||||
final chip = find.ancestor(
|
||||
of: find.text('Pending'),
|
||||
matching: find.byType(DecoratedBox),
|
||||
);
|
||||
for (final e in chip.evaluate()) {
|
||||
final box = (e.widget as DecoratedBox).decoration;
|
||||
if (box is! BoxDecoration) continue;
|
||||
final r = tester.getRect(find.byWidget(e.widget));
|
||||
if (r.width > word.width + 48) continue; // a surface, not a capsule
|
||||
expect(
|
||||
box.color,
|
||||
anyOf(isNull, Colors.transparent),
|
||||
reason: 'operational metadata is set, not stamped',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('the order row', () {
|
||||
testWidgets('shows the area, never a truncated full address', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester);
|
||||
await openKitchen(tester);
|
||||
|
||||
// `Peelamedu`, not `12 SNS Colony, Peelamedu, Coimb…`. The full address
|
||||
// is a detail-sheet fact; what the rider decides on here is the area.
|
||||
expect(find.textContaining('641004'), findsNothing);
|
||||
expect(find.textContaining('12 SNS Colony'), findsNothing);
|
||||
expect(find.text('Peelamedu'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('one order, one bag — and the column is stable', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester);
|
||||
await openKitchen(tester);
|
||||
|
||||
for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3']) {
|
||||
expect(find.text(bag), findsOneWidget);
|
||||
}
|
||||
|
||||
// Right-aligned on one axis, so the phone can be read against a shelf.
|
||||
final rights = [
|
||||
for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3'])
|
||||
tester.getRect(find.text(bag)).right,
|
||||
];
|
||||
for (final r in rights) {
|
||||
expect(r, closeTo(rights.first, 0.5));
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the name outranks the bag, and the bag yields first', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester, textScale: 2.0, width: 320);
|
||||
await openKitchen(tester);
|
||||
|
||||
// `Bag 1` is compared against a shelf, not read as prose, so it stops
|
||||
// scaling before the customer's name does. Left uncapped it took half a
|
||||
// 320pt row at 2× and the name — the one item that must yield last —
|
||||
// was what ellipsised.
|
||||
final bag = tester.getRect(find.text('Bag 1'));
|
||||
expect(
|
||||
bag.width,
|
||||
lessThan(120),
|
||||
reason: 'the bag column does not grow into the name',
|
||||
);
|
||||
expect(tester.takeException(), isNull, reason: 'and nothing overflows');
|
||||
});
|
||||
});
|
||||
|
||||
group('the offer states its own number', () {
|
||||
testWidgets('resting reads "Select all 3"', (tester) async {
|
||||
await pumpHome(tester);
|
||||
await openKitchen(tester);
|
||||
|
||||
// The count is what makes the offer an offer. It used to be bare, on the
|
||||
// argument that the card above said `3 orders` — and the card is gone.
|
||||
expect(find.text('Select all 3'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('partial and full both state the fraction', (tester) async {
|
||||
await pumpHome(tester, selected: const {'1'});
|
||||
await openKitchen(tester);
|
||||
expect(find.text('1 of 3 selected'), findsOneWidget);
|
||||
|
||||
await pumpHome(tester, selected: const {'1', '2', '3'});
|
||||
await openKitchen(tester);
|
||||
expect(find.text('All 3 selected · Clear'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user