Files
2026-08-11 13:16:33 +05:30

803 lines
37 KiB
Dart

part of 'pickups.dart';
// ─────────────────────────────────────────────────────────────────────────────
// UNIFIED STOP CARD
//
// One card renders all three operational modes — PICKUP, DELIVERY, and
// PICKUP & DELIVERY (combined) — because a rider scanning a route list must
// not have to learn three different layouts. The mode changes the accent, the
// badge, and the action breakdown; the *shape* never moves.
//
// Reading order, top to bottom, matches the order the rider asks the questions:
// 1. WHAT kind of stop, which number, and how far? → meta line
// 2. WHO and WHERE? → name + address
// 3. WHAT exactly do I do here? → action line
// 4. GO. → primary CTA
//
// Each of those is a ROW OF TEXT, not a panel. See the note inside build().
//
// Thumb zone: the CTA and the call button live in the bottom band of the card.
// Everything above is read-only — nothing the rider needs to *press* sits in
// the top 60%, which is where his hand covers the screen while reading.
// ─────────────────────────────────────────────────────────────────────────────
class PickupCard extends StatelessWidget {
final Map<String, dynamic> item;
final int displayStep;
/// Live straight-line distance from the rider to this stop, in metres.
/// Null when no usable fix exists — the strip then says so instead of
/// inventing a zero.
final double? distanceMeters;
/// The rider's current ground speed in m/s, if known. Drives the ETA;
/// [RouteMetricsHelper] falls back to an urban average when it's unusable.
final double? riderSpeedMps;
final bool enabled;
final bool isSkipped;
const PickupCard({
super.key,
required this.item,
required this.displayStep,
this.distanceMeters,
this.riderSpeedMps,
this.enabled = true,
this.isSkipped = false,
});
String _val(List<String> keys, [String fallback = '']) {
for (final k in keys) {
final v = item[k];
if (v != null && v.toString().trim().isNotEmpty) return v.toString();
}
return fallback;
}
/// Minimum 48dp tap targets, enforced against the raw logical pixel value so
/// a small-screen `.h` scale-down can never shrink below the floor.
///
/// Was 52dp with a 56dp CTA. The card's text carries the reading now — the
/// name, the address and the action line all went up a step — and a 56dp
/// button under bigger type made the bottom band the loudest thing on a card
/// whose job is to be scanned, not pressed. 48dp is still Material's floor
/// for a thumb, so nothing is harder to hit; the button just stops competing
/// with the words above it.
static double _tapTarget(double scaled) => math.max(48.0, scaled);
@override
Widget build(BuildContext context) {
final StopKind kind = stopKindOf(item);
final String customer = _val([
'pickupcustomer',
'tenantname',
], kind.hasDelivery ? 'Delivery point' : 'Pickup point');
final String address = _val([
'pickupaddress',
'PickupAddress',
], 'Address not available');
final String orderId = _val(['orderid']);
final String notes = _val(['notes', 'Notes']);
final String phone = _val(['pickupcontactno']);
final int deliverQty = deliveryParcelCount(item);
final int collectQty = pickupParcelCount(item);
final double cash = stopCollectionAmount(item);
// ── The card's one accent, driven by STATE not type ──
//
// This was `kind.accent`, which meant the stop *type* coloured the left
// stripe, the badge and — worst — the primary CTA. So "Start Pickup" was
// maroon, "Start Delivery" royal blue and "Start Combined Stop" indigo: the
// single most important control on the screen changed colour according to a
// category, and a rider scanning for "the button" had no constant to look
// for. Uber's rule is one accent moment per screen precisely because the
// accent is the only thing that says *this* matters.
//
// So type is now carried in words (see the meta line) and the accent is
// spent on state, which is what actually varies and matters: amber when this
// is a resume of a skipped stop, the brand accent otherwise.
final Color accent = isSkipped
? ColorConstants.warning
: ColorConstants.primary;
return Slidable(
key: ValueKey('slidable_$orderId'),
startActionPane: enabled
? ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.35,
children: [
SlidableAction(
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
onPressed: (_) => _startStop(context, gated: true),
// Same green as the Start button it is a shortcut for.
backgroundColor: ColorConstants.acceptGreen,
foregroundColor: Colors.white,
icon: isSkipped
? Icons.refresh_rounded
: Icons.navigation_rounded,
label: isSkipped ? 'Resume' : 'Start',
),
],
)
: null,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: enabled ? 1 : 0.7,
// ── The card itself opens the stop ──
//
// Tapping a card did nothing here. Everything it knows — the map, the
// full address, the parcel breakdown, the customer's number, the notes —
// was reachable only through the ⋯ menu or by starting the stop, which
// is a commitment rather than a look. Home has opened a detail sheet on
// tap since it was built; a rider moving between the two tabs learned
// one gesture and found it dead on the other.
//
// `HitTestBehavior.opaque` so the whole card responds, including its
// padding. The buttons inside keep their own taps — a child's gesture
// wins over an ancestor's — so Start, Call and ⋯ are unaffected.
// PressScale, not a bare GestureDetector: this card opens a sheet that
// takes ~340ms to arrive, and with no press state the rider got no
// acknowledgement at all in that gap — the classic "did it register?"
// second tap. The dip under the thumb is the receipt.
child: PressScale(
onTap: () => _openDetail(context),
semanticLabel: 'Stop $displayStep, $customer, $address',
semanticHint: 'Opens the full stop details',
child: Container(
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h),
// ── Fill, not an edge ──
//
// Border and shadow both gone; the fill separates the card from the
// page on its own, matching the Home stop card and the way most large
// apps draw a list card. The border, the shadow and the fill were
// three devices doing one job.
//
// See [ColorConstants.cardSurface] for why the fill sits 9.4% off the
// page rather than the ~5% a "subtle" grey gives: with no edge, it is
// the only thing left doing the separating, and it has to hold up in
// sunlight.
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
// The card's one piece of chrome: a 4px stripe down the left edge. It
// now carries STATE — amber when this is a skipped stop being resumed,
// the brand accent otherwise — and costs no vertical space at all,
// which the badges and panels it replaced all did. Stop type moved to
// the chip on the meta line, so the stripe no longer doubles as a
// colour-coded category marker.
//
// IntrinsicHeight is load-bearing, not decoration. The stripe is a
// Container with a width and no height, so it only fills the card
// when the Row stretches it — and `CrossAxisAlignment.stretch` sizes
// children to the Row's own cross-axis extent. Inside a ListView
// that extent is UNBOUNDED, so the stripe was asked to be infinitely
// tall and the whole card failed to lay out: every stop rendered as
// nothing while the progress rail above (a separate widget reading
// the same list) kept showing the right count. IntrinsicHeight gives
// the Row a real height first, so stretch has something finite to
// stretch to.
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 4.w,
color: enabled ? accent : accent.withValues(alpha: 0.35),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── One card, one box ──
//
// This stop card used to nest up to ten containers inside its
// own border: a filled type badge, a bordered "Stop #N" chip, an
// amber full-width ETA panel, a 30px tinted icon tile beside the
// address, a bordered "ACTION REQUIRED" panel holding two rows
// that each carried their own 24px tinted icon tile, and a
// bordered note panel — before reaching the button. Around 390px
// of height, so barely two stops fitted on a phone, and the
// rider scrolled to answer "where am I going next".
//
// Uber's Base system states the rule this broke: *the UI
// recedes, content leads* — containers stay subordinate to what
// they hold — and it prefers whitespace and contrast to layered
// elevation. A box does not make a thing important; being the
// biggest, darkest text on the card does.
//
// So every inner box is gone. What they contained is now type
// and colour on plain rows, and the accent survives in exactly
// two places: the card's left edge and the CTA. Uber calls that
// "a single accent moment"; four tinted panels competing was the
// opposite of one.
//
// Result is roughly half the height, so three to four stops fit
// where two did.
// ── Meta line: what kind of stop, which one, how far ──
Padding(
padding: EdgeInsets.fromLTRB(14.w, 11.h, 12.w, 0),
child: Row(
children: [
// Darkened filled chip — a mark, not a surface. The card
// stays neutral, so the type colour never becomes a pane.
//
// Flexible with an inner ellipsis, because the label is the
// only elastic thing on this line: "PICKUP & DELIVERY" is
// nearly three times the width of "PICKUP", and it shares
// the row with a stop number and the distance chip, both of
// which have a fixed claim. The label is what gives way — a
// truncated type word still reads, an overflowing row does
// not.
Flexible(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: enabled
? kind.chipColor
: kind.chipColor.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
kind.icon,
size: 12.sp,
color: Colors.white,
),
SizedBox(width: 5.w),
Flexible(
child: Text(
kind.chipLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
color: Colors.white,
fontFamily:
FontConstants.fontFamily,
),
),
),
],
),
),
),
if (displayStep > 0) ...[
SizedBox(width: 7.w),
Text(
'Stop $displayStep',
maxLines: 1,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
SizedBox(width: 8.w),
const Spacer(),
// Distance and ETA lost their amber panel but not their
// prominence — this is still the pair he checks at every red
// light, so it is the one bordered thing on the line.
_DistanceEta(
meters: distanceMeters,
speedMps: riderSpeedMps,
muted: !enabled,
),
],
),
),
// ── Identity: the name is the thing he shouts at the gate ──
Padding(
padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
height: 1.15,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 3.h),
// Two lines, not three. A third line of street
// address never changes which gate he walks to, and
// it cost every card in the list 18px.
//
// See [kFullAddressTrial] for the design switch that
// lifts this on both stop-card screens at once.
Text(
address,
maxLines: kFullAddressTrial
? kFullAddressMaxLines
: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
height: 1.35,
fontWeight: FontWeight.w600,
// Slate, not the brown M3 leftover, and dark
// enough to stay legible on the card's fill.
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
if (phone.isNotEmpty) ...[
SizedBox(width: 8.w),
_CallButton(phone: phone),
],
],
),
),
// ── What to do here, as one line of text ──
_ActionLine(
kind: kind,
deliverQty: deliverQty,
collectQty: collectQty,
cash: cash,
skipped: isSkipped,
),
// ── Customer note: a line with an icon, not a panel ──
if (notes.isNotEmpty)
Padding(
padding: EdgeInsets.fromLTRB(14.w, 6.h, 12.w, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.sticky_note_2_rounded,
size: 13.sp,
color: ColorConstants.warning,
),
SizedBox(width: 6.w),
Expanded(
child: Text(
notes,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.3,
fontWeight: FontWeight.w700,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
// ── 5. Primary CTA — the only thing to press ──
Padding(
padding: EdgeInsets.fromLTRB(
14.w,
12.h,
14.w,
12.h,
),
child: Row(
children: [
Expanded(
child: SizedBox(
height: _tapTarget(48.h),
// ── Green, filled, and the loudest thing
// on the card ──
//
// This was the stop-kind accent — brand
// maroon on a pickup — which put the
// forward action in the same hue the app
// uses for Reject and for destructive
// confirmations. One rule now holds across
// the whole run: green advances the job,
// maroon is brand and navigation, red
// destroys. See [MilerButton].
//
// Hand-rolled `ElevatedButton` replaced by
// the shared button so the radius, the
// label size and the press state match
// every other CTA the rider presses today.
child: enabled
? MilerButton(
label: isSkipped
? 'Resume stop'
: kind.startCtaLabel,
icon: isSkipped
? Icons.refresh_rounded
: Icons.arrow_forward_rounded,
color: ColorConstants.acceptGreen,
height: _tapTarget(48.h),
onPressed: () =>
_startStop(context),
)
: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius:
BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color:
ColorConstants.borderStrong,
),
),
child: Text(
'Finish the current stop first',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13.5.sp,
color: ColorConstants
.secondaryText,
fontFamily:
FontConstants.fontFamily,
),
),
),
),
),
if (!isSkipped) ...[
SizedBox(width: 10.w),
_SquareIconButton(
icon: Icons.more_horiz_rounded,
size: _tapTarget(48.h),
background: ColorConstants.pureSurface,
border: ColorConstants.borderStrong,
iconColor: ColorConstants.slateText,
semanticLabel: 'More options',
onTap: () {
final parentState = context
.findAncestorStateOfType<
_MyPickupsState
>();
if (parentState != null) {
_showMyOptionsSheet(
context,
item,
parentState,
);
}
},
),
],
],
),
),
],
),
),
],
),
),
),
),
),
),
);
}
/// Everything the app knows about this stop, in the same sheet Home uses.
///
/// Deliberately the same widget rather than a Bookings-specific one: the two
/// screens show the same stop, and two sheets would drift.
void _openDetail(BuildContext context) {
final parentState = context.findAncestorStateOfType<_MyPickupsState>();
StopDetailSheet.show(
context,
stop: item,
stopNumber: displayStep,
riderLat: parentState?._currentLocation?.latitude,
riderLng: parentState?._currentLocation?.longitude,
);
}
/// Opens the stop's map/navigation screen.
///
/// [gated] applies the on-duty check — the swipe action can fire from a
/// resting thumb, so it verifies duty first; the explicit CTA does not
/// re-ask because the card is only enabled for an on-duty rider anyway.
Future<void> _startStop(BuildContext context, {bool gated = false}) async {
final parentState = context.findAncestorStateOfType<_MyPickupsState>();
if (parentState == null) return;
if (gated) {
if (!await parentState.ensureOnlineForAction()) return;
if (!context.mounted) return;
}
await openScreen(
context,
_PickupMapScreen(pickup: item, parentState: parentState),
swipeToGoBack: false,
);
}
}
// ── Distance + ETA, as one chip ──────────────────────────────────────────────
//
// Was a full-width amber panel with its own border, on the argument that this
// is the one number that changes while the rider is moving. The argument was
// right; the panel was not the way to honour it. A tinted band 40px tall
// pushed the address and the button down on every card in the list, and in
// sunlight an amber fill on white reads as a grey smudge anyway — the very
// thing the card's own comments warned about for badges.
//
// Bare text on the meta line was the correction, and it went one step too far:
// the two live figures ended up sharing a line with the type badge and the stop
// number in a similar weight, so nothing marked where the static half of the
// line ended and the moving half began. A single pill fixes that at no cost in
// height — it wraps BOTH figures, so distance and ETA read as one fact about
// one journey rather than two numbers that happen to be adjacent, and the
// chip's edge does the separating that four different font weights were trying
// to do. One chip, not two: they are never useful apart.
class _DistanceEta extends StatelessWidget {
final double? meters;
final double? speedMps;
final bool muted;
const _DistanceEta({
required this.meters,
required this.speedMps,
this.muted = false,
});
@override
Widget build(BuildContext context) {
final distance = RouteMetricsHelper.formatDistance(meters);
final travel = RouteMetricsHelper.formatDuration(
RouteMetricsHelper.travelTime(meters, liveSpeedMps: speedMps),
);
if (distance == '—') return const SizedBox.shrink();
final Color fg = muted
? ColorConstants.secondaryText
: ColorConstants.slateText;
return Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 4.h),
// White, not the app's neutral grey: the card underneath is now grey, and
// a grey chip on a grey card has no edge at all. Inner surfaces go lighter
// than their container.
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
border: Border.all(
color: muted
? ColorConstants.borderSubtle
: ColorConstants.borderStrong,
width: 1.2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.near_me_rounded, size: 13.sp, color: fg),
SizedBox(width: 5.w),
Text(
travel == '—' ? distance : '$distance · $travel',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: fg,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}
// ── What to do here ──────────────────────────────────────────────────────────
//
// Replaces the bordered "ACTION REQUIRED" panel, its all-caps heading, and the
// two rows inside it that each carried a 24px tinted icon tile — four boxes to
// say "Collect 3 parcels, cash ₹340".
//
// One line of text says the same thing. A combined stop still gets both jobs,
// separated by a bullet and each in its own leg colour, so the "TWO jobs here"
// signal the panel existed for survives: it is now carried by colour and by
// the word "and", which a rider reads faster than he parses a stacked panel.
class _ActionLine extends StatelessWidget {
final StopKind kind;
final int deliverQty;
final int collectQty;
final double cash;
final bool skipped;
const _ActionLine({
required this.kind,
required this.deliverQty,
required this.collectQty,
required this.cash,
required this.skipped,
});
static String _money(double v) {
if (v >= 100) return '₹${v.round()}';
final rounded = (v * 100).round() / 100;
return rounded == rounded.roundToDouble()
? '₹${rounded.round()}'
: '₹${rounded.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
final spans = <InlineSpan>[];
TextStyle style(Color c) => TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w800,
color: c,
fontFamily: FontConstants.fontFamily,
);
// Parcel legs are neutral; only the cash is coloured. These used to be
// royal blue and pink respectively, which is colour spent on naming a leg
// that the word beside it already names — and it made the line read as three
// unrelated fragments. Matches [_MetaLine] on the Home stop row.
if (kind.hasDelivery) {
final n = deliverQty > 0 ? deliverQty : 1;
spans.add(
TextSpan(text: 'Deliver $n', style: style(ColorConstants.slateText)),
);
}
if (kind.hasPickup) {
if (spans.isNotEmpty) {
spans.add(
TextSpan(text: ' · ', style: style(ColorConstants.borderStrong)),
);
}
final n = collectQty > 0 ? collectQty : 1;
spans.add(
TextSpan(text: 'Collect $n', style: style(ColorConstants.slateText)),
);
}
if (spans.isEmpty) return const SizedBox.shrink();
if (cash > 0) {
spans.add(
TextSpan(text: ' · ', style: style(ColorConstants.secondaryText)),
);
spans.add(
TextSpan(
// `acceptGreen` is a fill colour; as text on the card fill it measures
// 2.28:1. See [ColorConstants.moneyGreen].
text: 'Collect ${_money(cash)}',
style: style(ColorConstants.moneyGreen),
),
);
}
if (skipped) {
spans.add(
TextSpan(
text: ' · Skipped earlier',
style: style(ColorConstants.warning),
),
);
}
return Padding(
padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0),
child: Text.rich(
TextSpan(children: spans),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
}
class _CallButton extends StatelessWidget {
final String phone;
const _CallButton({required this.phone});
@override
Widget build(BuildContext context) {
return _SquareIconButton(
icon: Icons.call_rounded,
size: math.max(52.0, 52.w),
// Neutral, like its twin on the Home stop row: a green-tinted tile put a
// second green control beside a green-adjacent CTA, and green already
// means "done" elsewhere in the app. Calling is an affordance, not a state.
background: ColorConstants.pureSurface,
border: ColorConstants.borderStrong,
iconColor: ColorConstants.slateText,
semanticLabel: 'Call customer',
onTap: () async {
// A dialer that fails to open is silent by default: the rider is at
// the door, taps Call, and nothing happens. Say so, and show him the
// number so he can dial it himself.
final ok = await launchPhoneDialer(phone);
if (!ok && context.mounted) {
AppFeedback.error(context, "Couldn't open the dialer — $phone");
}
},
);
}
}
// ── Square icon button with a guaranteed tap target ──────────────────────────
class _SquareIconButton extends StatelessWidget {
final IconData icon;
final double size;
final Color background;
final Color border;
final Color iconColor;
final String semanticLabel;
final VoidCallback onTap;
const _SquareIconButton({
required this.icon,
required this.size,
required this.background,
required this.border,
required this.iconColor,
required this.semanticLabel,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel,
child: Material(
color: background,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: border, width: 1.5),
),
child: Icon(icon, color: iconColor, size: 22.sp),
),
),
),
);
}
}