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

View File

@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.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';
/// ─────────────────────────────────────────────────────────────────────────
/// THE KITCHEN IS THE CARD; ITS ORDERS ARE THE CONTENTS
///
/// Home's pickup model is one place per card, disclosed to reveal the orders
/// under it, ticked in batches and advanced with one gesture:
///
/// ```
/// Vidhya Kitchen · 4 bags · 2 to accept ˅
/// ◉ Joe Gandhipuram Bag 1
/// ◉ Priya RS Puram Bag 2
/// ```
///
/// ── The regression this file exists for ──
///
/// Two functions answered "what is this place called" and read different keys:
/// `MilkRun.sourceNameOf` took `sourcename` / `kitchenname`, `stopSourceName`
/// took those **and** the CamelCase `SourceName` / `KitchenName` the payload
/// sometimes carries. `trip_card.dart` uses both in one expression, so a
/// booking with only the capitalised key was:
///
/// • **not flat** — `stopSourceName` found a counter, so the group became a
/// foldable place with its orders underneath; and
/// • **titled "pickup"** — `sourceNameOf` found none, so `navigationLabel`
/// fell through to its leg description.
///
/// On a device that is a group headed by a backend word, which the rider taps
/// and which opens a detail sheet instead of the list. Fixed at the domain
/// layer: one reader, four keys, `stopSourceName` delegating to it.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
tearDown(() {
Get.reset();
ServiceProfile.setActive(ServiceProfile.parcel);
});
/// An order as the queue hands it over. [kitchenKey] is the variable under
/// test: every spelling the payload uses must group and name identically.
Map<String, dynamic> order(
String id,
String who,
String drop, {
String kitchenKey = 'KitchenName',
String status = 'new',
}) => {
'orderid': id,
'pickupid': int.parse(id),
'step': int.parse(id) - 1000,
kitchenKey: 'Vidhya Kitchen',
'sourceid': 'K1',
'pickupcustomer': who,
'dropaddress': drop,
'pickupaddress': '9 Kitchen Street, Coimbatore',
'orderstatus': status,
'type': 'delivery',
'pickuplat': 11.01,
'pickuplon': 76.95,
};
Future<void> pump(
WidgetTester tester,
List<Map<String, dynamic>> stops, {
Set<String> accepted = const {},
Set<String> selected = const {},
Set<String> collected = const {},
double width = 390,
double scale = 1.0,
}) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
tester.view.physicalSize = Size(width * 3, 1400 * 3);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
minTextAdapt: true,
builder: (_, _) => MaterialApp(
home: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
child: Scaffold(
body: SingleChildScrollView(
child: TripCard(
trip: Trip.fromStops(id: 'T1', stops: stops),
acceptedIds: accepted,
rejectedIds: const {},
collectedIds: collected,
selectedIds: selected,
onToggleSelect: (_) {},
),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
group('the place is named from the domain, not the screen', () {
for (final key in const [
'sourcename',
'SourceName',
'kitchenname',
'KitchenName',
]) {
testWidgets('$key names the card and groups its orders', (tester) async {
await pump(tester, [
order('1001', 'Joe', 'Gandhipuram, Coimbatore', kitchenKey: key),
order('1002', 'Priya', 'RS Puram, Coimbatore', kitchenKey: key),
]);
expect(find.text('Vidhya Kitchen'), findsOneWidget);
// The leg description must never surface as a place's name.
expect(find.text('pickup'), findsNothing);
// Grouped, so the orders are disclosed under the header rather than the
// header being the stop.
expect(find.text('Joe'), findsOneWidget);
expect(find.text('Priya'), findsOneWidget);
});
}
testWidgets('a payload naming no counter still renders its one stop', (
tester,
) async {
final o = order('1001', 'Joe', 'Gandhipuram, Coimbatore');
o.remove('KitchenName');
o.remove('sourceid');
await pump(tester, [o]);
// Nothing to group under, so the header IS the stop — named for the
// customer, never for the leg.
expect(find.text('Joe'), findsWidgets);
expect(find.text('pickup'), findsNothing);
});
});
group('the row carries what the rider decides on', () {
testWidgets('the drop area, not the booking reference', (tester) async {
await pump(tester, [order('1001', 'Joe', 'Gandhipuram, Coimbatore')]);
// "Are Joe and Priya the same trip out of the door, or opposite ends of
// the city?" is the question this list answers. A reference answers the
// one the office asks, once, when something has gone wrong — it lives on
// the detail sheet and the Activity record.
expect(find.text('Gandhipuram'), findsOneWidget);
expect(find.textContaining('1001'), findsNothing);
expect(find.text('Bag 1'), findsOneWidget);
});
// Updated when `areaOf` was corrected against real Coimbatore addresses
// on a device: it used to take the first part with no digits in it, which
// is the *street*. The row's job is to say which neighbourhood a stop is
// in, so the rule now prefers the shortest digit-free part — the locality
// — and this expectation follows the rule rather than the other way round.
testWidgets('a door number is skipped for the area behind it', (
tester,
) async {
await pump(tester, [
order('1001', 'Joe', '12, Cross Cut Road, Gandhipuram'),
]);
expect(find.text('Gandhipuram'), findsOneWidget);
expect(find.textContaining('12,'), findsNothing);
});
testWidgets('no drop means no line, never a blank one', (tester) async {
final o = order('1001', 'Joe', '');
o.remove('dropaddress');
o.remove('pickupaddress');
await pump(tester, [o]);
expect(find.text('Joe'), findsWidgets);
expect(find.text(''), findsNothing);
});
testWidgets('one order is one bag, counted off the orders', (tester) async {
await pump(tester, [
order('1001', 'Joe', 'Gandhipuram, Coimbatore'),
order('1002', 'Priya', 'RS Puram, Coimbatore'),
order('1003', 'Arun', 'Peelamedu, Coimbatore'),
]);
// The header states the load once, in the unit the rider carries; the
// check that orders and bags agree is the manifest itself, one named
// bag per order. `N orders · N bags` was the same number twice and it
// cost the header its `to accept` clause on a real route.
expect(find.textContaining('3 bags'), findsWidgets);
for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3']) {
expect(find.text(bag), findsOneWidget);
}
});
});
group('a partly-accepted kitchen says so', () {
testWidgets('2 of 4 taken is visible on the header', (tester) async {
await pump(
tester,
[
order('1001', 'Joe', 'Gandhipuram, Coimbatore'),
order('1002', 'Priya', 'RS Puram, Coimbatore'),
order('1003', 'Arun', 'Peelamedu, Coimbatore'),
order('1004', 'Kumar', 'Saibaba Colony, Coimbatore'),
],
accepted: const {'1002', '1004'},
);
// The rider is mid-decision at one counter; the count of what is still
// undecided is the whole content of that state.
expect(find.textContaining('4 bags'), findsWidgets);
expect(find.textContaining('to accept'), findsWidgets);
});
});
group('it survives a narrow phone and a large font', () {
for (final (width, scale) in const [
(390.0, 1.0),
(375.0, 1.0),
(320.0, 1.0),
(390.0, 2.0),
(375.0, 2.0),
(320.0, 2.0),
]) {
testWidgets('${width.toInt()}px at ${scale}x', (tester) async {
await pump(
tester,
[
order(
'1001',
'Ramachandran Venkataraman',
'Saibaba Colony, Coimbatore',
),
order('1002', 'Priya', 'RS Puram, Coimbatore'),
],
accepted: const {'1002'},
selected: const {'1001'},
width: width,
scale: scale,
);
// A `RenderFlex` overflow throws, and on a doorstep the stripe bar
// reads as the app having crashed.
expect(tester.takeException(), isNull);
});
}
});
}