Files
doormile_milderapp/test/two_lines_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

331 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
import 'package:miler/views/Dashboard/home/trip_progress_rail.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ONE APP, TWO DAYS
///
/// The whole merge rests on one promise, in both directions:
///
/// • A rider whose login carries a **meal tenant id** gets the meal process —
/// kitchen loading, deliveries, no money, no return leg.
/// • A rider **without** one gets the parcel app exactly as it was: hub to hub,
/// bookings, cash, per-stop decisions.
///
/// The second half of that is the one that can break silently. Nobody files a
/// bug saying "the meal build looks fine"; a parcel rider losing his cash screen
/// or gaining a kitchen heading is found at a customer's door. So every meal
/// assertion below has a parcel one beside it.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> stop({
required String id,
String status = 'ASSIGNED',
String kitchen = '',
num cash = 0,
}) => {
'orderid': id,
'step': int.parse(id),
'pickupcustomer': 'Customer $id',
'pickupaddress': '$id Main Road, RS Puram',
'orderstatus': status,
if (kitchen.isNotEmpty) 'kitchenname': kitchen,
if (kitchen.isNotEmpty) 'baglabel': 'BAG-$id',
if (cash > 0) 'collectionamt': cash,
'pickuplat': 13.08,
'pickuplon': 80.21,
};
Future<void> pumpRoute(
WidgetTester tester, {
required List<Map<String, dynamic>> stops,
}) async {
tester.view.physicalSize = const Size(1080, 2600);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final trip = Trip.fromStops(id: 't', stops: stops);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: trip,
onToggleSelect: (_) {},
onViewRoute: () {},
).buildStops(),
),
),
),
),
);
await tester.pumpAndSettle();
}
// ══════════════════════════════════════════════════════════════════════
// THE MEAL LOGIN — what a tenant id buys
// ══════════════════════════════════════════════════════════════════════
group('a rider who logs in on a meal tenant', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.meals));
testWidgets('starts at a kitchen, and never comes back to a hub', (
tester,
) async {
await pumpRoute(
tester,
stops: [
stop(id: '1', kitchen: 'Kitchen 1'),
stop(id: '2', kitchen: 'Kitchen 1'),
],
);
// ── Ported to the timeline ──
//
// `START · KITCHEN` was the hub row's label, and the hub row is gone with
// the cards: the run opens on one line-agnostic head instead. What that
// row was really holding is the half below, which is unchanged — a meal
// run has no return leg.
expect(find.text('TODAY’S RUN'), findsOneWidget);
// His day ends at the last subscriber's door. A return leg to a warehouse
// he has never seen is an un-completable step at the end of a route he
// has in fact finished.
expect(find.text('RETURN · HUB'), findsNothing);
expect(find.text('START · HUB'), findsNothing);
});
testWidgets('sees his route grouped under the kitchen it comes from', (
tester,
) async {
await pumpRoute(
tester,
stops: [
stop(id: '1', kitchen: 'Kitchen 1'),
stop(id: '2', kitchen: 'Kitchen 2'),
],
);
// Two kitchens, two headings — the shape of a meal day, which a flat list
// hides until he opens every card.
expect(find.text('Kitchen 1'), findsOneWidget);
expect(find.text('Kitchen 2'), findsOneWidget);
// And the bag, which is what he checks against the box in his hand.
// The label the kitchen printed, as [BagManifest] hands it over — the
// `Bag ` prefix belonged to the card's chip and went with it, which
// stopped a printed `BAG-1` reading as `Bag BAG-1`.
expect(find.text('BAG-1'), findsOneWidget);
});
test('works deliveries, not bookings', () {
final p = ServiceProfile.active;
expect(p.workTabLabel, 'Deliveries');
expect(p.jobNounPlural, 'deliveries');
expect(p.completionVerb, 'Delivered');
});
test('hands a subscriber food — he does not collect from one', () {
// Every stop after the kitchen is a drop, whether or not the payload
// bothered to label it.
expect(stopKindOf(stop(id: '1')).isDelivery, isTrue);
});
test('is never asked for money, however the payload is priced', () {
// Subscribers pay the client monthly. A rupee figure at the door is a
// question the rider cannot answer and must not be asked.
expect(stopCollectionAmount(stop(id: '1', cash: 450)), 0);
expect(ServiceProfile.active.collectsCash, isFalse);
});
test('takes the whole assignment, not stop by stop', () {
// He cannot decline one subscriber's lunch.
expect(ServiceProfile.active.acceptsPerStop, isFalse);
});
test('photographs the crate once, at the counter', () {
expect(ServiceProfile.active.bulkLoadProof, isTrue);
});
test('keeps the order on Home until it is physically in his box', () {
// Accepting changes nothing physical — the food is still in a kitchen he
// has not reached.
expect(ServiceProfile.active.handsOffAtCollection, isTrue);
});
});
// ══════════════════════════════════════════════════════════════════════
// THE PARCEL LOGIN — nothing moved
// ══════════════════════════════════════════════════════════════════════
group('a rider who logs in without a meal tenant', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
testWidgets('gets the hub-to-hub route he has always had', (tester) async {
await pumpRoute(
tester,
stops: [
stop(id: '1'),
stop(id: '2'),
],
);
// The parcel day is still a loop: he leaves the hub empty, fills up at
// customer doors and comes back. The head is shared with the meal run;
// the closing leg is what tells the two apart.
expect(find.text('TODAY’S RUN'), findsOneWidget);
expect(find.text('RETURN · HUB'), findsOneWidget);
expect(find.text('START · KITCHEN'), findsNothing);
});
testWidgets('gets no kitchen sections and no meal chrome', (tester) async {
// Nothing from the meal run may leak onto a parcel route. The *source
// pill* is data-driven and stays if a parcel payload ever names one —
// see [StopSourceLine] — but the grouping, its counts and its bulk
// collect are meal-flow structure and must not appear.
await pumpRoute(
tester,
stops: [stop(id: '1', kitchen: 'Kitchen 1')],
);
expect(find.text('1 meal · 1 to accept'), findsNothing);
expect(find.textContaining('ready to collect'), findsNothing);
expect(find.text('MEAL'), findsNothing);
// The stop is drawn — it is just drawn flat. A parcel route has no
// counter to group under, so the timeline is one node per stop and the
// header *is* the stop. `PICKUP` was the card's type mark; the type is
// progressive disclosure in the detail sheet now.
expect(find.text('Customer 1'), findsOneWidget);
expect(find.text('Kitchen 1'), findsNothing);
// "Collect 1" on the meta line is the parcel card's own count of what he
// is picking up at this door, and stays. The heading assertions above are
// what tell the two apart.
});
test('still works bookings', () {
final p = ServiceProfile.active;
expect(p.workTabLabel, 'Bookings');
expect(p.jobNounPlural, 'bookings');
expect(p.completionVerb, 'Picked up');
});
test('still collects the cash the stop is priced at', () {
// The failure this app cannot afford: a rider at a door with no way to
// record the money he is holding.
expect(stopCollectionAmount(stop(id: '1', cash: 450)), 450);
expect(ServiceProfile.active.collectsCash, isTrue);
});
test('still decides stop by stop', () {
expect(ServiceProfile.active.acceptsPerStop, isTrue);
});
test('an unlabelled stop is still a pickup', () {
expect(stopKindOf(stop(id: '1')), StopKind.pickup);
});
});
// ══════════════════════════════════════════════════════════════════════
// THE SWITCH ITSELF
// ══════════════════════════════════════════════════════════════════════
group('what decides which day he gets', () {
Future<ServiceProfile> loginWith(Map<String, Object> prefs) async {
SharedPreferences.setMockInitialValues(prefs);
final p = await resolveServiceProfile();
ServiceProfile.setActive(p);
return p;
}
test('a recognised meal tenant name puts him on the meal line', () async {
final p = await loginWith({'tenantname': 'DailyGrubs'});
expect(p.line, ServiceLine.milkMan);
});
test(
'no tenant, an unknown tenant, or a blank one is a parcel day',
() async {
expect((await loginWith({})).line, ServiceLine.parcel);
expect((await loginWith({'tenantid': 4242})).line, ServiceLine.parcel);
expect((await loginWith({'tenantname': ''})).line, ServiceLine.parcel);
},
);
test('the fallback direction is the safe one', () async {
// Wrong-way round, a live parcel rider loses the screen that records a
// customer's cash. This way, a meal rider sees a payment prompt he can
// dismiss.
final p = await loginWith({'tenantname': 'something-nobody-configured'});
expect(p.collectsCash, isTrue);
});
});
// ── The round does not end at a warehouse ──
//
// A logistics day is a loop: leave the hub empty, fill up at customer doors,
// return with the consignments — so "then hub" is the true last leg and the
// rider is not finished until he is there. A meal rider finishes at the last
// subscriber's door and goes home. Telling him his round ends at a building he
// has never visited is the same fiction the RETURN · HUB row was removed from
// Home for, and it survived on the progress rail.
group("the progress rail speaks each line's own language", () {
Future<String> subtitle(WidgetTester tester) async {
tester.view.physicalSize = const Size(1080, 1200);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: TripProgressRail(
progress: const [
StopProgress.done,
StopProgress.current,
StopProgress.pending,
],
slotLabel: 'Trip 1',
remaining: const Duration(minutes: 25),
),
),
),
),
);
await tester.pumpAndSettle();
return tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data ?? '')
.firstWhere((d) => d.contains('left'), orElse: () => '');
}
testWidgets('a meal round just ends', (tester) async {
ServiceProfile.setActive(ServiceProfile.meals);
final line = await subtitle(tester);
expect(line, contains('left'));
expect(
line,
isNot(contains('hub')),
reason: 'he goes home from the last door, not to a warehouse',
);
});
testWidgets('a parcel day is a loop, and says so', (tester) async {
ServiceProfile.setActive(ServiceProfile.parcel);
final line = await subtitle(tester);
expect(line, contains('then hub'));
});
});
}