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,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
@@ -39,7 +40,79 @@ import 'package:miler/widget/Bottom_page.dart';
/// untick each stop by hand, which on a six-stop route is six taps to undo one
/// mistake.
/// ─────────────────────────────────────────────────────────────────────────
/// What the ticked stops are ready for.
///
/// A service rider works a kitchen as a batch: he ticks the five parcels that
/// come from it, accepts them, walks in, slides *arrived*, is handed the crate,
/// slides *picked up*. One gesture per rung for the whole set rather than five
/// gestures per rung — the five bags arrived as one handover and the control
/// should match what happened.
enum SelectionAction {
/// Take these stops on.
accept,
/// Say he has reached the source.
arrive,
/// Say the parcels are in his box. The last rung on Home.
pickUp,
}
extension SelectionActionX on SelectionAction {
/// The words on the control, in the rider's voice.
///
/// "Mark as…" rather than "I've…": these two are *claims recorded against a
/// place*, and the rider is not talking to the app, he is marking a set of
/// orders. The wording also matches what the sheet behind it then asks him
/// to confirm, so the button and the sheet read as one action rather than
/// two.
String get label => switch (this) {
SelectionAction.accept => 'Accept',
SelectionAction.arrive => 'Mark as Arrived',
SelectionAction.pickUp => 'Mark as Picked',
};
/// The status token this rung writes.
String get status => switch (this) {
SelectionAction.accept => 'ACCEPTED',
SelectionAction.arrive => 'ARRIVED',
SelectionAction.pickUp => 'PICKED',
};
/// Accepting commits from the bar; the two that move real parcels go through
/// a sheet with a slide in it.
///
/// Not decoration. "Picked" is the rider asserting he is holding somebody's
/// lunch, and it is the last thing standing between the order and a delivery
/// list — worth a deliberate gesture, and worth taking his eyes off a list of
/// cards for the second it takes. Accepting is reversible and happens in bulk
/// at the start of a shift, so it stays a tap.
///
/// The slide used to be *on* this bar, inline. It lost: a 44dp-high track
/// squeezed between a ✕ and the screen edge is a gesture the rider misses
/// with a thumb on a moving bike, and there was no room left to say what he
/// was confirming. The bar now says what will happen and [StopActionSheet]
/// gives the gesture a whole surface — see the note there.
bool get confirmsInSheet => this != SelectionAction.accept;
}
class SelectionBar extends StatelessWidget {
/// The tallest this bar gets, excluding the device inset.
///
/// ── Why this is declared rather than guessed ──
///
/// Home reserved `80.h` of scroll extent to clear whatever floats over it.
/// That number was measured once, by eye, against one of the two surfaces
/// that can occupy the slot — and every later change to either (a second
/// line of copy, a taller button, a text scale the tester did not try) made
/// it wrong silently, by hiding the end of the route under the bar.
///
/// A control that overlays a list is the only thing that knows how much of
/// the list it covers, so it says so. `ButtonSizes.primary` is the action
/// row; the rest is this widget's own vertical padding, and the two-line
/// accessibility layout is covered because the button grows with it.
static double get maxHeight => ButtonSizes.primary + 28.h;
/// How many stops are ticked. Zero renders nothing.
final int count;
@@ -56,6 +129,14 @@ class SelectionBar extends StatelessWidget {
/// True while the accept is in flight.
final bool busy;
/// Which rung the ticked stops are on. Anything past [SelectionAction.accept]
/// swaps the buttons for a slide and drops Reject — a stop already taken on
/// cannot be declined from here.
final SelectionAction action;
/// Runs the rung named by [action]. Falls back to [onAccept] when null.
final Future<void> Function()? onAdvance;
const SelectionBar({
super.key,
required this.count,
@@ -63,6 +144,8 @@ class SelectionBar extends StatelessWidget {
required this.onClear,
this.onReject,
this.busy = false,
this.action = SelectionAction.accept,
this.onAdvance,
});
@override
@@ -88,86 +171,140 @@ class SelectionBar extends StatelessWidget {
// read that way against a list of white cards.
elevation: 0,
child: Container(
padding: EdgeInsets.fromLTRB(6.w, 8.h, 10.w, 8.h),
// 8 → 6. The bar is a *command surface*, not a card: what should
// read as generous is the buttons on it, not the air around them.
padding: EdgeInsets.fromLTRB(6.w, 6.h, 8.w, 6.h),
// Borderless, like every other surface on this screen: the lift is
// what says "this is over the list", and a hairline under a shadow
// only muddies the edge.
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle),
boxShadow: DesignConstants.shadowLg,
borderRadius: BorderRadius.circular(22),
boxShadow: DesignConstants.shadowFloat,
),
child: Row(
children: [
_ClearButton(onTap: busy ? null : onClear),
SizedBox(width: 2.w),
// The running count, in words — but only when there is room for
// it. With two decisions on the bar there is not: at 320pt the
// three fixed controls plus this line overflowed by 94pt, and
// "Accept 3" already carries the number. So the sentence is a
// luxury for the one-button case and the space goes to the
// buttons otherwise.
if (onReject == null)
Expanded(
child: Text(
'$count ${count == 1 ? 'stop' : 'stops'} selected',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
child: action.confirmsInSheet
// ── The rung, as one big button ──
//
// At this point in the shift the rider is standing at a counter
// holding a crate, and there is exactly one thing to press.
// Nothing shares the bar with it: no Reject (these orders are
// already his), no running-count sentence (the count is on the
// button), no slide track squeezed to whatever width was left.
//
// It is a full-width control because it is pressed with a thumb,
// one-handed, without looking — and because the gesture that
// actually commits is one layer away, in the sheet it opens, so
// this press is safe to make easy.
//
// 56 → 48. At 56 inside an 8pt-padded bar the whole thing stood
// 72pt off the nav bar and covered the last two rows of the
// route it was acting on — a command surface must not hide its
// own subject. 48 is the same button one size down the app's
// own scale, still 20pt above the tap-target floor, and it
// matches the height Reject and Accept use on the other branch
// so the bar does not change stature between rungs.
? Row(
children: [
_ClearButton(onTap: busy ? null : onClear),
SizedBox(width: 6.w),
Expanded(
child: MilerButton(
// `Mark as Arrived (3)`. The count in brackets, not
// after a middot: a dotted suffix reads as a second
// fact about the action, and this is the action's
// own object — how many stops the press closes.
label: '${action.label} ($count)',
icon: action == SelectionAction.arrive
? LucideIcons.mapPinCheck
: LucideIcons.shoppingBag,
color: action == SelectionAction.arrive
? ColorConstants.primary
: ColorConstants.acceptGreen,
height: ButtonSizes.secondary,
loading: busy,
onPressed: busy ? null : () => onAdvance?.call(),
),
),
),
],
)
else
const Spacer(),
SizedBox(width: 6.w),
// ── Both decisions, on the bar ──
//
// Reject used to live on every stop row and went when the rows
// became tick-only. That left the rider able to say yes to a
// selection and nothing else: declining a stop he could not do
// — shop shut, address wrong — had no route at all, and the hub
// learned about it by the stop simply never being accepted.
//
// So it comes back where Accept is, on the set he has picked.
// Outlined and unlabelled-by-count against Accept's filled
// green: they are not equal choices, and the one that ends work
// should not look like the one that starts it.
if (onReject != null) ...[
Flexible(
child: MilerButton(
// No icon, and flexible: the width an icon costs is width
// the two labels need side by side on a 320pt phone, and
// under real pressure the pair should give ground evenly
// rather than run off the edge of the bar.
label: 'Reject',
variant: MilerButtonVariant.outlined,
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
expand: false,
onPressed: busy ? null : onReject,
),
: Row(
children: [
_ClearButton(onTap: busy ? null : onClear),
SizedBox(width: 2.w),
// The running count, in words — but only when there is
// room for it. With two decisions on the bar there is
// not: at 320pt the three fixed controls plus this line
// overflowed by 94pt, and "Accept 3" already carries the
// number. So the sentence is a luxury for the one-button
// case and the space goes to the buttons otherwise.
if (onReject == null)
Expanded(
child: Text(
'$count ${count == 1 ? 'stop' : 'stops'} selected',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
)
else
const Spacer(),
SizedBox(width: 6.w),
// ── Both decisions, on the bar ──
//
// Reject used to live on every stop row and went when the
// rows became tick-only. That left the rider able to say
// yes to a selection and nothing else: declining a stop he
// could not do — shop shut, address wrong — had no route
// at all, and the hub learned about it by the stop simply
// never being accepted.
//
// So it comes back where Accept is, on the set he has
// picked. Outlined against Accept's filled green: they are
// not equal choices, and the one that ends work should not
// look like the one that starts it.
if (onReject != null) ...[
// Capped, not flexed. [MilerButton] fills whatever box
// it is given, so these are the widths the two labels
// actually need — anything larger and the pair overflows
// the bar on a 390pt phone, anything flexible and the
// Spacer steals half.
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 104.w),
child: MilerButton(
label: 'Reject',
variant: MilerButtonVariant.outlined,
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
expand: false,
onPressed: busy ? null : onReject,
),
),
SizedBox(width: 8.w),
],
// Content-hugging, so the count on the left keeps its room
// on a narrow phone and the label never ellipsises — a
// bulk action reading "Accept 3 sto…" is not one the rider
// will trust.
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 142.w),
child: MilerButton(
label: 'Accept ($count)',
icon: LucideIcons.checkCheck,
color: ColorConstants.acceptGreen,
height: ButtonSizes.secondary,
expand: false,
loading: busy,
onPressed: onAccept,
),
),
],
),
SizedBox(width: 8.w),
],
// Content-hugging, so the count on the left keeps its room on a
// narrow phone and the label never ellipsises — a bulk action
// that reads "Accept 3 sto…" is not one the rider will trust.
Flexible(
child: MilerButton(
label: 'Accept $count',
icon: Icons.done_all_rounded,
color: ColorConstants.acceptGreen,
height: ButtonSizes.secondary,
expand: false,
loading: busy,
onPressed: onAccept,
),
),
],
),
),
),
),
@@ -191,7 +328,7 @@ class _ClearButton extends StatelessWidget {
width: ButtonSizes.minTapTarget,
height: ButtonSizes.minTapTarget,
child: Icon(
Icons.close_rounded,
LucideIcons.x,
size: 20.sp,
color: ColorConstants.secondaryText,
),