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

330
test/two_lines_test.dart Normal file
View File

@@ -0,0 +1,330 @@
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'));
});
});
}