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 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, ), ), ), ); } }