first commit
This commit is contained in:
802
lib/views/Dashboard/pickups/card.dart
Normal file
802
lib/views/Dashboard/pickups/card.dart
Normal file
@@ -0,0 +1,802 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user