803 lines
37 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|