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

@@ -1,8 +1,11 @@
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/trip.dart';
import 'package:miler/views/Dashboard/home/trip_progress_rail.dart';
import 'package:miler/Models/stop_status.dart';
/// The whole point of replacing the node rail is that route length no longer
/// changes the widget's shape. A hub can assign 6 stops or 40; the summary at
@@ -35,8 +38,12 @@ void main() {
await tester.pumpAndSettle();
}
List<StopProgress> route(int done, int skipped, int pending,
{bool current = true}) {
List<StopProgress> route(
int done,
int skipped,
int pending, {
bool current = true,
}) {
return [
for (var i = 0; i < done; i++) StopProgress.done,
if (current) StopProgress.current,
@@ -45,16 +52,18 @@ void main() {
];
}
testWidgets('height does not grow with the number of stops',
(tester) async {
testWidgets('height does not grow with the number of stops', (tester) async {
await pump(tester, route(2, 1, 3));
final short = tester.getSize(find.byType(TripProgressRail)).height;
await pump(tester, route(8, 3, 28)); // 40 stops
final long = tester.getSize(find.byType(TripProgressRail)).height;
expect(long, short,
reason: 'the summary must not stretch or squeeze with route length');
expect(
long,
short,
reason: 'the summary must not stretch or squeeze with route length',
);
});
testWidgets('never overflows, even on a long route', (tester) async {
@@ -74,8 +83,7 @@ void main() {
);
});
testWidgets('drops the skip clause when nothing was skipped',
(tester) async {
testWidgets('drops the skip clause when nothing was skipped', (tester) async {
await pump(tester, route(3, 0, 5));
expect(find.text('On stop 4 of 9 · 2h 10m left, then hub'), findsOneWidget);
});
@@ -87,20 +95,64 @@ void main() {
await pump(tester, route(4, 2, 11));
final boxes = tester
.widgetList<Container>(find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(Container),
))
.widgetList<Container>(
find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(Container),
),
)
.map((c) => c.decoration)
.whereType<BoxDecoration>();
for (final d in boxes) {
// ── A ring on a node is a mark; a border on a rectangle is a box ──
//
// The rule this protects is that the rail sits bare on the page — no
// card, no panel, no outline around the run. Circular nodes are exempt
// and always were in spirit: the route timeline draws hollow rings for
// exactly the same reason, and the rail's two framing nodes (where the
// rider is, where his day ends) are hollow so a place he does not work
// never competes with one he does.
if (d.shape == BoxShape.circle) continue;
expect(d.border, isNull, reason: 'no bordered container inside the rail');
expect(d.boxShadow, anyOf(isNull, isEmpty),
reason: 'no elevation inside the rail');
expect(
d.boxShadow,
anyOf(isNull, isEmpty),
reason: 'no elevation inside the rail',
);
}
});
// ── A run of one is a journey, not a badge ──
//
// A rider with a single accepted order met one red circled **1** at the top
// of Deliveries: a counter, or a notification badge, but not a route. The rail
// is framed by the two places the run actually goes between — where he is
// standing, and where the day ends — so even one stop reads as
// "here → this pickup → done".
testWidgets('one stop still reads as a journey', (tester) async {
await pump(tester, route(1, 0, 0));
expect(
find.byIcon(LucideIcons.locateFixed),
findsOneWidget,
reason: 'the run starts where the rider is',
);
// Parcel is the default profile in these tests: its day ends at the depot.
expect(find.byIcon(LucideIcons.warehouse), findsOneWidget);
expect(find.text('1'), findsOneWidget);
});
testWidgets('a milk run ends at home, not at a warehouse', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
await pump(tester, route(1, 0, 0));
expect(find.byIcon(LucideIcons.house), findsOneWidget);
expect(find.byIcon(LucideIcons.warehouse), findsNothing);
});
testWidgets('a finished route says head back to the hub', (tester) async {
await pump(tester, route(6, 0, 0, current: false));
expect(find.text('All stops done'), findsOneWidget);
@@ -109,7 +161,9 @@ void main() {
// The rail numbers the stops the same way the list below it does, and the
// finished ones are the coloured ones. This is the whole point of the nodes.
testWidgets('draws a numbered node per stop, and one scooter', (tester) async {
testWidgets('draws a numbered node per stop, and one scooter', (
tester,
) async {
await pump(tester, route(2, 0, 2)); // 5 stops, current is #3
// Every stop keeps its number, including the one he is on — the scooter
@@ -117,14 +171,15 @@ void main() {
for (final n in ['1', '2', '3', '4', '5']) {
expect(find.text(n), findsOneWidget, reason: 'stop $n is on the rail');
}
expect(find.byIcon(Icons.two_wheeler_rounded), findsOneWidget);
expect(find.byIcon(LucideIcons.bike), findsOneWidget);
});
// The point of the overlay: finishing a stop *moves* the marker, visibly,
// instead of repainting two circles.
testWidgets('the scooter rides forward when a stop is completed',
(tester) async {
Finder scooter() => find.byIcon(Icons.two_wheeler_rounded);
testWidgets('the scooter rides forward when a stop is completed', (
tester,
) async {
Finder scooter() => find.byIcon(LucideIcons.bike);
await pump(tester, route(2, 0, 2)); // on stop 3 of 5
final from = tester.getCenter(scooter()).dx;
@@ -145,17 +200,21 @@ void main() {
testWidgets('a finished route parks the scooter', (tester) async {
await pump(tester, route(2, 0, 3, current: false)); // 5 stops, none active
// Nothing is `current`, so the first pending stop holds the scooter.
expect(find.byIcon(Icons.two_wheeler_rounded), findsOneWidget);
expect(find.byIcon(LucideIcons.bike), findsOneWidget);
await pump(tester, route(5, 0, 0, current: false)); // all done
expect(find.byIcon(Icons.two_wheeler_rounded), findsNothing,
reason: 'nothing left to ride to');
expect(
find.byIcon(LucideIcons.bike),
findsNothing,
reason: 'nothing left to ride to',
);
});
// Long routes scroll rather than shrink — the failure that killed the first
// version of this rail was nodes being squeezed to fit a fixed width.
testWidgets('a long route scrolls instead of crushing its nodes',
(tester) async {
testWidgets('a long route scrolls instead of crushing its nodes', (
tester,
) async {
await pump(tester, route(4, 0, 35)); // 40 stops
expect(find.byType(SingleChildScrollView), findsWidgets);
expect(tester.takeException(), isNull);
@@ -166,4 +225,98 @@ void main() {
expect(find.text('START'), findsNothing);
expect(find.text('RETURN'), findsNothing);
});
// ── The scrolling edge has to read as "more route", not as a clip ──
//
// A 21-stop round draws about seven nodes on a 390pt phone and sliced the
// eighth down its middle at the viewport edge. A half circle stopping in mid
// air reads as a rendering fault, and it is the one edge that has to invite
// the push that reveals the rest of the day.
testWidgets('a scrolling rail fades at its edges; a short one does not', (
tester,
) async {
await pump(tester, route(4, 0, 35)); // 40 stops — scrolls
expect(
find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(ShaderMask),
),
findsOneWidget,
reason: 'the rail continues past the edge and must say so',
);
// Four. The rail reserves a framing node at each end and holds a 22pt
// floor under the gaps, so against a 390pt phone's 358pt of content width
// only six nodes — four stops — lay out without scrolling. Which is worth
// knowing on its own: the fade is not a long-route embellishment, it is
// what almost every real round gets.
await pump(tester, route(1, 0, 2)); // 4 stops — fits
expect(
find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(ShaderMask),
),
findsNothing,
reason:
'a route that fits ends where it ends — fading it would hint at '
'work that does not exist',
);
});
// ── The rung → rail mapping, in both legs' vocabulary ──
//
// `stopProgressFor` is read on the Deliveries tab, where every stop is past
// `pickup-complete` by definition. It used to ask `isActive || arrived` —
// both pickup-leg rungs — so the customer's door the rider was standing at
// matched nothing and drew grey.
group('stopProgressFor', () {
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
test('the door the rider is at is where the scooter goes', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(
stopProgressFor(StopStatus.deliveryArrived),
StopProgress.current,
reason:
'he is standing at it — this is the whole question the rail '
'answers',
);
});
test('the pickup leg still resolves', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(stopProgressFor(StopStatus.arrived), StopProgress.current);
expect(stopProgressFor(StopStatus.active), StopProgress.current);
});
test('a carried bag is not a delivered one', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
// `picked` and `outForDelivery` are both "aboard, not handed over". They
// are ahead of the rider, not behind him — see `picked_not_active_test`.
expect(stopProgressFor(StopStatus.picked), StopProgress.pending);
expect(stopProgressFor(StopStatus.outForDelivery), StopProgress.pending);
expect(stopProgressFor(StopStatus.delivered), StopProgress.done);
});
test('done is line-aware', () {
ServiceProfile.setActive(ServiceProfile.parcel);
expect(
stopProgressFor(StopStatus.picked),
StopProgress.done,
reason: 'collection IS the job on logistics',
);
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(
stopProgressFor(StopStatus.picked),
StopProgress.pending,
reason: 'on a round the same rung is the middle of the morning',
);
});
test('a skipped stop keeps its own state', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(stopProgressFor(StopStatus.skipped), StopProgress.skipped);
});
});
}