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>
340 lines
16 KiB
Dart
340 lines
16 KiB
Dart
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';
|
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
|
import 'package:miler/widget/Bottom_page.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// SELECTION BAR — the contextual action for a multi-stop pick
|
|
///
|
|
/// Appears the moment the rider ticks a stop and leaves when he unticks the
|
|
/// last one. While it is up it owns the bulk action: the inline "Accept all N
|
|
/// stops" button in the trip's footer stands down, so there is never a choice
|
|
/// of two bulk buttons pointing at different sets of stops.
|
|
///
|
|
/// ── Why this floats instead of sitting in the list ──
|
|
///
|
|
/// It used to be an inline button in the footer, below the stop rows. That put
|
|
/// a *response to a direct action* at the bottom of a scroll: tick a stop near
|
|
/// the top of a six-stop route and the button confirming what you had just done
|
|
/// was off-screen. The rider had no feedback that the tick had registered
|
|
/// beyond the tick itself, and no way to act on it without scrolling past every
|
|
/// remaining stop.
|
|
///
|
|
/// A selection is transient and it is *modal in spirit* — the screen is in a
|
|
/// different mode while it is non-empty. Every app that does multi-select the
|
|
/// way riders already know it, Gmail and Google Photos included, answers that
|
|
/// with a contextual bar pinned to the edge of the viewport: it appears with
|
|
/// the selection, it is always reachable, and it leaves without a trace. The
|
|
/// count lives on it, so the rider never has to go looking for how many he has
|
|
/// picked.
|
|
///
|
|
/// ── Clearing ──
|
|
///
|
|
/// The ✕ is on the left, where a dismiss belongs and where it cannot be hit by
|
|
/// a thumb going for Accept. Without it the only way out of a selection was to
|
|
/// 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;
|
|
|
|
/// Accepts exactly the ticked stops.
|
|
final VoidCallback? onAccept;
|
|
|
|
/// Declines exactly the ticked stops, after asking why. Null hides the
|
|
/// action.
|
|
final VoidCallback? onReject;
|
|
|
|
/// Drops the whole selection.
|
|
final VoidCallback onClear;
|
|
|
|
/// 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,
|
|
required this.onAccept,
|
|
required this.onClear,
|
|
this.onReject,
|
|
this.busy = false,
|
|
this.action = SelectionAction.accept,
|
|
this.onAdvance,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
|
|
// body, so `SafeArea` knows nothing about it and anything pinned to the
|
|
// bottom lands underneath. See [BottomPage.bottomInset].
|
|
//
|
|
// A fixed inset now. This used to follow a `navVisible` notifier up and
|
|
// down, because the bar left the screen entirely on scroll. It compacts
|
|
// instead of leaving, so there is no longer a state in which the room is
|
|
// free — and chasing the 20pt difference re-laid this out every time the
|
|
// rider changed scroll direction, for a gap he would never notice.
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
|
child: Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
// A real lift, not the page's usual whisper: this is the one surface
|
|
// on Home that is temporarily above everything else, and it has to
|
|
// read that way against a list of white cards.
|
|
elevation: 0,
|
|
child: Container(
|
|
// 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(22),
|
|
boxShadow: DesignConstants.shadowFloat,
|
|
),
|
|
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(),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClearButton extends StatelessWidget {
|
|
final VoidCallback? onTap;
|
|
const _ClearButton({this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
label: 'Clear selection',
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
child: SizedBox(
|
|
width: ButtonSizes.minTapTarget,
|
|
height: ButtonSizes.minTapTarget,
|
|
child: Icon(
|
|
LucideIcons.x,
|
|
size: 20.sp,
|
|
color: ColorConstants.secondaryText,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|