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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1273
lib/views/Dashboard/pickups/collect_payment.dart
Normal file
1273
lib/views/Dashboard/pickups/collect_payment.dart
Normal file
File diff suppressed because it is too large
Load Diff
979
lib/views/Dashboard/pickups/done.dart
Normal file
979
lib/views/Dashboard/pickups/done.dart
Normal file
@@ -0,0 +1,979 @@
|
||||
part of 'pickups.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PICKUP DONE SCREEN — PREMIUM REDESIGN (Scratch Card + Standard)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class PickupsDone extends StatefulWidget {
|
||||
final bool isCancelled;
|
||||
final bool isDelivery;
|
||||
final int bonusPoints;
|
||||
final List<Map<String, dynamic>>? remainingOrders;
|
||||
final String? paymentMethod;
|
||||
final _MyPickupsState? parentState;
|
||||
|
||||
const PickupsDone({
|
||||
super.key,
|
||||
this.isCancelled = false,
|
||||
this.isDelivery = false,
|
||||
this.bonusPoints = 0,
|
||||
this.remainingOrders,
|
||||
this.paymentMethod,
|
||||
this.parentState,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PickupsDone> createState() => _PickupsDoneState();
|
||||
}
|
||||
|
||||
class _PickupsDoneState extends State<PickupsDone>
|
||||
with TickerProviderStateMixin {
|
||||
late ConfettiController _confettiController;
|
||||
final GlobalKey<ScratcherState> _scratcherKey = GlobalKey<ScratcherState>();
|
||||
double _opacity = 0.0;
|
||||
bool _isScratched = false;
|
||||
|
||||
late AnimationController _entranceController;
|
||||
late Animation<double> _circleScaleAnim;
|
||||
late Animation<double> _fadeAnim;
|
||||
late Animation<Offset> _slideAnim;
|
||||
|
||||
/// The next few stops, capped for display.
|
||||
List<Map<String, dynamic>> _nextOrders = [];
|
||||
|
||||
/// How many stops are actually left, before the display cap. The screen
|
||||
/// tells the rider where he is in the day, and "3 left" when 11 remain is
|
||||
/// worse than saying nothing.
|
||||
int _remainingCount = 0;
|
||||
bool _loadingNextOrders = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_confettiController = ConfettiController(
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
|
||||
// 800 → 300. This screen sits between two stops and the rider crosses it
|
||||
// 40 times a shift; an eight-hundred-millisecond entrance is half a second
|
||||
// of standing still, 40 times. The celebration branch keeps its own longer
|
||||
// beat — see [DesignConstants.motionCelebrate] — because that fires once
|
||||
// per completed route, not once per stop.
|
||||
_entranceController = AnimationController(
|
||||
duration: DesignConstants.motionPage,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_circleScaleAnim = CurvedAnimation(
|
||||
parent: _entranceController,
|
||||
curve: const Interval(0.0, 0.6, curve: Curves.elasticOut),
|
||||
);
|
||||
_fadeAnim = CurvedAnimation(
|
||||
parent: _entranceController,
|
||||
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
|
||||
);
|
||||
_slideAnim = Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero)
|
||||
.animate(
|
||||
CurvedAnimation(
|
||||
parent: _entranceController,
|
||||
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
|
||||
),
|
||||
);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
_entranceController.forward();
|
||||
// Celebrate only when it's actually special: a real bonus reveal.
|
||||
// Route-completion confetti fires from _loadNextOrders once we know no
|
||||
// stops remain. A plain mid-route stop gets a calm screen (no confetti)
|
||||
// so the rider isn't slowed by fireworks 15 times a shift.
|
||||
if (!widget.isCancelled && widget.bonusPoints > 0) {
|
||||
_confettiController.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_loadNextOrders();
|
||||
}
|
||||
|
||||
/// Fires the confetti only when the route is finished (no stops left). A
|
||||
/// bonus reveal already celebrates in initState, and cancelled stops never do.
|
||||
void _maybeCelebrateRouteComplete() {
|
||||
if (!mounted || widget.isCancelled || widget.bonusPoints > 0) return;
|
||||
if (_nextOrders.isEmpty) _confettiController.play();
|
||||
}
|
||||
|
||||
Future<void> _loadNextOrders() async {
|
||||
try {
|
||||
// If parent passed remaining bookings, use those
|
||||
if (widget.remainingOrders != null) {
|
||||
final live = widget.remainingOrders!
|
||||
.where((o) => !stopStatusOf(o).isFinishedPickup)
|
||||
.toList();
|
||||
setState(() {
|
||||
_remainingCount = live.length;
|
||||
_nextOrders = live.take(3).toList();
|
||||
_loadingNextOrders = false;
|
||||
});
|
||||
_maybeCelebrateRouteComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch from SharedPreferences / API
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
|
||||
|
||||
if (userId == 0) {
|
||||
if (mounted) setState(() => _loadingNextOrders = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final provider = PickupProvider();
|
||||
final items = await provider.getPickupQueuesPicked();
|
||||
|
||||
List<Map<String, dynamic>> next = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
|
||||
// Fallback when the server returns nothing. On the live backend the
|
||||
// accepted store is the load-bearing record of bookings the rider has
|
||||
// taken on (the backend keeps them as 'miler_assigned'), so use it to
|
||||
// surface the remaining stops — completed/cancelled ones are removed from
|
||||
// the store on finish, so they won't show here. The mock list is only
|
||||
// added on the old demo path.
|
||||
if (next.isEmpty) {
|
||||
final accepted = await getAcceptedBookings();
|
||||
next = [...accepted];
|
||||
}
|
||||
|
||||
// Nothing left is a real answer here — it is what "Return to hub" is for.
|
||||
// The debug demo-route fallback that used to sit here is gone with the
|
||||
// rest of the mock layer.
|
||||
|
||||
// Keep only pickups that aren't already completed/cancelled.
|
||||
next = next.where((o) => !stopStatusOf(o).isFinishedPickup).toList();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_remainingCount = next.length;
|
||||
_nextOrders = next.take(3).toList();
|
||||
_loadingNextOrders = false;
|
||||
});
|
||||
_maybeCelebrateRouteComplete();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[DONE] Error loading next bookings: $e');
|
||||
if (mounted) setState(() => _loadingNextOrders = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_confettiController.dispose();
|
||||
_entranceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Opens the next pickup stop's navigation screen, replacing this completion
|
||||
/// screen so the rider can't tap back into it. When no stops remain, returns
|
||||
/// to the hub (Bookings tab).
|
||||
void _goToNextStop({Map<String, dynamic>? order}) {
|
||||
final Map<String, dynamic>? next =
|
||||
order ?? (_nextOrders.isNotEmpty ? _nextOrders.first : null);
|
||||
if (next != null) {
|
||||
// Use the same screen as "Start pickup" so every stop has an identical
|
||||
// flow: preview → navigate → arrive → verify → confirm → next stop.
|
||||
// Thread the real pickups state through so each stop's completion updates
|
||||
// the one live list (removes it, unblocks the following stop).
|
||||
Get.off(
|
||||
() => _PickupMapScreen(pickup: next, parentState: widget.parentState),
|
||||
);
|
||||
} else {
|
||||
Get.offAll(() => const BottomPage(initialIndex: 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's single surface. With nothing on the page boxed, there is
|
||||
/// only one background, and everything — including the pinned CTA bar — is
|
||||
/// drawn straight onto it.
|
||||
static Color get _pageBg => ColorConstants.daylightSurface;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool showScratchCard = !widget.isCancelled && widget.bonusPoints > 0;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _pageBg,
|
||||
body: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: showScratchCard
|
||||
? _buildScratchCardContent()
|
||||
: _buildStandardContent(),
|
||||
),
|
||||
// Confetti overlay
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConfettiWidget(
|
||||
confettiController: _confettiController,
|
||||
blastDirectionality: BlastDirectionality.explosive,
|
||||
shouldLoop: false,
|
||||
numberOfParticles: 30,
|
||||
gravity: 0.3,
|
||||
emissionFrequency: 0.05,
|
||||
colors: [
|
||||
ColorConstants.primary,
|
||||
ColorConstants.acceptGreen,
|
||||
Color(0xFF007AFF),
|
||||
Color(0xFFFFB800),
|
||||
Color(0xFFAF52DE),
|
||||
],
|
||||
createParticlePath: drawStar,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Standard Picked up / Cancelled ─────────────────────────────────────────
|
||||
//
|
||||
// A handoff, not a trophy — and one surface, not five.
|
||||
//
|
||||
// ── What was wrong with the previous version ──
|
||||
//
|
||||
// Every group on this screen was a bordered, filled, rounded rectangle: a
|
||||
// green banner, a shadowed hero card, a bordered list card with a pill inside
|
||||
// it and a filled purple badge inside that. Four containers, three border
|
||||
// weights and a shadow, stacked down a page holding about forty words.
|
||||
//
|
||||
// The cost is not ugliness, it is *ranking*. A border says "this is a thing,
|
||||
// separate from its neighbours". Give that emphasis to all four groups and
|
||||
// none of them is emphasised: the rider's eye lands on whichever box is
|
||||
// biggest rather than on whichever fact matters, and the thing he opened the
|
||||
// screen for — where am I going next — has to be found rather than seen.
|
||||
//
|
||||
// ── What replaces them ──
|
||||
//
|
||||
// Nothing is boxed. Rank is carried by the things that carry rank for free:
|
||||
//
|
||||
// • **Type size.** The stop name is 28sp against a 12.5sp status line. That
|
||||
// ratio alone answers "what is this screen about" before anything is read.
|
||||
// • **Colour weight.** Finished work is grey with a small green tick; the
|
||||
// live destination is near-black. Completed information should be legible
|
||||
// and ignorable, which is exactly what grey-on-neutral is.
|
||||
// • **Whitespace.** A 26pt gap groups more cleanly than a 1px border does,
|
||||
// and it costs no visual noise to do it.
|
||||
// • **One hairline**, between the hero and the list — the single place where
|
||||
// a genuine change of subject happens.
|
||||
//
|
||||
// The order is the rider's own sentence: you finished that → this is next →
|
||||
// then these → press this. Roughly one second, top to bottom, without a
|
||||
// single edge to parse.
|
||||
Widget _buildStandardContent() {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
// ── The top margin is not decoration ──
|
||||
//
|
||||
// This screen has no app bar — there is nowhere to go back to, so
|
||||
// there is nothing for one to hold — and `SafeArea` only clears the
|
||||
// notch. With 10pt after it the status line began about 34pt down
|
||||
// an 844pt page, hard under the clock, and every page in the app
|
||||
// that *does* have a bar starts its content around 90pt. So the
|
||||
// handoff read as the one screen missing its header rather than as
|
||||
// the one screen that does not need one.
|
||||
//
|
||||
// 34pt puts the first line clear of the system chrome and gives the
|
||||
// page a top edge of its own. It is the same trick Uber's post-trip
|
||||
// screens use: no bar, but a margin deep enough that the absence
|
||||
// reads as deliberate.
|
||||
padding: EdgeInsets.fromLTRB(20.w, 34.h, 20.w, 32.h),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: SlideTransition(
|
||||
position: _slideAnim,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_statusLine(),
|
||||
if (_loadingNextOrders)
|
||||
_findingNextStop()
|
||||
else if (_nextOrders.isEmpty)
|
||||
_routeComplete()
|
||||
else ...[
|
||||
// Deliberately wider than the 34pt margin above the
|
||||
// status line. Equal gaps left that line belonging to
|
||||
// neither the top edge nor the hero — it just hung there.
|
||||
// Tighter above than below groups it with the top of the
|
||||
// page, which is what it is: a footnote on the screen the
|
||||
// rider just left, not an introduction to this one.
|
||||
SizedBox(height: 42.h),
|
||||
_nextStopHero(_nextOrders.first),
|
||||
if (_nextOrders.length > 1) _upNext(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_handoffBar(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// What just happened, and how much is left — on one grey line.
|
||||
///
|
||||
/// This was a filled, bordered green banner with a bordered white pill inside
|
||||
/// it, about 60pt tall, and it was the loudest object on a screen whose
|
||||
/// subject is somewhere else entirely. It is *completed* information: the
|
||||
/// rider performed the act himself thirty seconds ago and is not deciding
|
||||
/// anything with it. He needs to be able to confirm it, not to be told it.
|
||||
///
|
||||
/// So the green survives only in the tick — enough to answer "did that
|
||||
/// register?" at a glance — and the words drop to the same grey as every
|
||||
/// other secondary caption in the app. The count rides the same line, dot
|
||||
/// separated, because "delivered" and "9 left" are one thought about the
|
||||
/// shift, not two facts needing two containers.
|
||||
Widget _statusLine() {
|
||||
final isCancelled = widget.isCancelled;
|
||||
final method = widget.paymentMethod?.trim() ?? '';
|
||||
final done = widget.isDelivery ? 'Delivered' : 'Picked up';
|
||||
|
||||
final parts = <String>[
|
||||
isCancelled ? 'Not completed' : done,
|
||||
if (!isCancelled && method.isNotEmpty) method,
|
||||
if (_remainingCount > 0)
|
||||
'$_remainingCount ${_remainingCount == 1 ? 'stop' : 'stops'} left',
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCancelled ? Icons.cancel_rounded : Icons.check_circle_rounded,
|
||||
size: 16.sp,
|
||||
color: isCancelled
|
||||
? ColorConstants.errorRed
|
||||
: ColorConstants.acceptGreen,
|
||||
),
|
||||
SizedBox(width: 7.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
parts.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The next stop, at the size of the decision it is.
|
||||
///
|
||||
/// No card, no border, no shadow. A hero that needs a box drawn round it to
|
||||
/// read as the hero is not yet a hero — and this one does not: at 28sp
|
||||
/// against a 12.5sp status line above it and 15sp rows below, it is the only
|
||||
/// thing on the screen the eye can land on first.
|
||||
///
|
||||
/// The stop type moves from a filled purple badge floated to the right into a
|
||||
/// dotted label under the address. Same information, ranked correctly: the
|
||||
/// rider decides *whether to go* from the name and the address, and only then
|
||||
/// asks what he will be doing when he arrives. A saturated chip at the top
|
||||
/// asked that question first and in the loudest voice on the screen.
|
||||
Widget _nextStopHero(Map<String, dynamic> stop) {
|
||||
final kind = stopKindOf(stop);
|
||||
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
|
||||
final address = (stop['pickupaddress'] ?? 'Address not available')
|
||||
.toString();
|
||||
final kms = (stop['kms'] ?? '').toString();
|
||||
final skipped =
|
||||
(stop['orderstatus']?.toString().toLowerCase() ?? '') == 'skipped';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('NEXT STOP', style: _eyebrow),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 28.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.9,
|
||||
height: 1.08,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
address,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Row(
|
||||
children: [
|
||||
// The one place the type colour appears, and it appears as a 6pt
|
||||
// dot. Enough to separate a delivery from a pickup at a glance
|
||||
// across a day of these; not enough to compete with the name.
|
||||
Container(
|
||||
width: 6.w,
|
||||
height: 6.w,
|
||||
decoration: BoxDecoration(
|
||||
color: kind.accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
kind.titleLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: kind.accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (kms.isNotEmpty && kms != '0') ...[
|
||||
Text(' · ', style: _metaStyle),
|
||||
Text('$kms km away', style: _metaStyle),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (skipped) ...[SizedBox(height: 14.h), _retryNote()],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The one place on this screen that still earns a fill.
|
||||
///
|
||||
/// It is not a group of related content — it is an exception, and an
|
||||
/// exception that contradicts what the rider remembers: he passed this stop
|
||||
/// earlier and is being sent back. A tinted strip is how the app marks that
|
||||
/// everywhere else, and dropping it to a plain line here would hide the one
|
||||
/// fact on the screen he cannot infer from anything around it.
|
||||
Widget _retryNote() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.replay_rounded,
|
||||
size: 15.sp,
|
||||
color: ColorConstants.warning,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You skipped this one earlier — this is the retry',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.warning,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops after the next one: one list, not a stack of cards.
|
||||
///
|
||||
/// These are context, never a decision — the rider only ever acts on the stop
|
||||
/// in front of him. So they get the quietest treatment that is still tappable:
|
||||
/// a numbered circle, two lines of text, a chevron, and a hairline between
|
||||
/// rows inset to the text so the column reads as one list rather than as
|
||||
/// separate objects.
|
||||
///
|
||||
/// The single full-width rule above the section is the only border on the
|
||||
/// screen. It is there because this is the one point where the subject
|
||||
/// genuinely changes — from the stop he is going to, to the ones after it.
|
||||
Widget _upNext() {
|
||||
final later = _nextOrders.skip(1).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 32.h),
|
||||
Container(height: 1, color: ColorConstants.borderSubtle),
|
||||
SizedBox(height: 22.h),
|
||||
Text(
|
||||
'UP NEXT · ${later.length} ${later.length == 1 ? 'STOP' : 'STOPS'}',
|
||||
style: _eyebrow,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
for (var i = 0; i < later.length; i++) ...[
|
||||
if (i > 0)
|
||||
Padding(
|
||||
// Inset to the text column, not to the numbers. A rule that runs
|
||||
// under the circles cuts the list into slabs; one that starts
|
||||
// where the names start reads as a separator inside a single list.
|
||||
padding: EdgeInsets.only(left: 39.w),
|
||||
child: Container(height: 1, color: ColorConstants.borderSubtle),
|
||||
),
|
||||
_upNextRow(later[i], i + 2),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _upNextRow(Map<String, dynamic> stop, int position) {
|
||||
final kind = stopKindOf(stop);
|
||||
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
|
||||
final address = (stop['pickupaddress'] ?? '').toString();
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _goToNextStop(order: stop),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 15.h),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26.w,
|
||||
height: 26.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: kind.accent.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$position',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: kind.accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 13.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
if (address.isNotEmpty) ...[
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _findingNextStop() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 78.h),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 22.w,
|
||||
height: 22.w,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
Text(
|
||||
'Finding your next stop…',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The one moment on this screen that has earned a celebration: the route is
|
||||
/// finished. Confetti already fires from [_maybeCelebrateRouteComplete].
|
||||
///
|
||||
/// Still no card. The green disc is a *mark* — it is the subject of the
|
||||
/// message, not a container holding it — which is the line this screen draws
|
||||
/// between a shape that is allowed and one that is not.
|
||||
Widget _routeComplete() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(8.w, 58.h, 8.w, 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.acceptGreen.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.warehouse_rounded,
|
||||
size: 30.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
Text(
|
||||
'Every stop done',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.6,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'Head back to the hub to drop the parcels and hand in any cash '
|
||||
'you collected.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pinned CTA.
|
||||
///
|
||||
/// Same background as the page and a single hairline above it, rather than a
|
||||
/// white slab with a shadow. The bar is not a separate surface floating over
|
||||
/// the content — it is the bottom of the page with the button held in it, and
|
||||
/// on a screen that has just given up every other border it would be the
|
||||
/// heaviest edge left if it kept its own.
|
||||
Widget _handoffBar() {
|
||||
final hasNext = _nextOrders.isNotEmpty;
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: _pageBg,
|
||||
border: Border(
|
||||
top: BorderSide(color: ColorConstants.borderSubtle, width: 1),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: MilerButton(
|
||||
label: _loadingNextOrders
|
||||
? 'Loading…'
|
||||
: hasNext
|
||||
? 'Move to next stop'
|
||||
: 'Return to hub',
|
||||
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
|
||||
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
|
||||
loading: _loadingNextOrders,
|
||||
onPressed: _loadingNextOrders ? null : () => _goToNextStop(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Section labels. Small, letterspaced, grey — the quietest way to name a
|
||||
/// group, and the reason none of the groups needs an outline.
|
||||
TextStyle get _eyebrow => TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.3,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
);
|
||||
|
||||
TextStyle get _metaStyle => TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
);
|
||||
|
||||
// ── Scratch Card Content ───────────────────────────────────────────────────
|
||||
Widget _buildScratchCardContent() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 1),
|
||||
|
||||
ScaleTransition(
|
||||
scale: _circleScaleAnim,
|
||||
child: Container(
|
||||
width: 68.w,
|
||||
height: 68.w,
|
||||
// ── Celebration, in the app's own voice ──
|
||||
//
|
||||
// This was an amber gradient over a 20px coloured glow, in two
|
||||
// hex values that appear nowhere else in the product — so the one
|
||||
// moment a rider is being congratulated looked like it came from
|
||||
// a different app. It stays visually special (it is the only
|
||||
// filled disc on any handoff screen, and it still scales in with
|
||||
// an elastic curve) but it is now the app's own reward colour,
|
||||
// flat, the way every other surface here is.
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.rewardAmber,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.card_giftcard_rounded,
|
||||
size: 30.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'You Won a Bonus!',
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
'Scratch the card to reveal your bonus points',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Scratch Card
|
||||
FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 250.w,
|
||||
height: 250.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFFFFB800).withValues(alpha: 0.15),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
child: Scratcher(
|
||||
key: _scratcherKey,
|
||||
brushSize: 50,
|
||||
threshold: 50,
|
||||
color: ColorConstants.primary,
|
||||
onChange: (value) {},
|
||||
onThreshold: () {
|
||||
_confettiController.play();
|
||||
setState(() {
|
||||
_opacity = 1.0;
|
||||
_isScratched = true;
|
||||
});
|
||||
},
|
||||
image: Image.asset(
|
||||
'assets/images/doormile_icon.png',
|
||||
fit: BoxFit.scaleDown,
|
||||
width: 100,
|
||||
height: 100,
|
||||
),
|
||||
child: Container(
|
||||
width: 250,
|
||||
height: 250,
|
||||
color: ColorConstants.pureSurface,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.monetization_on_rounded,
|
||||
size: 64.sp,
|
||||
color: const Color(0xFFFFB800),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
'${widget.bonusPoints}',
|
||||
style: TextStyle(
|
||||
fontSize: 52.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
letterSpacing: -2,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Bonus Points',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_isScratched)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16.h),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.touch_app_rounded,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Text(
|
||||
'Scratch the card to reveal',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
_buildDoneButton(isEnabled: _isScratched),
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// ── One button, not a second implementation of one ──
|
||||
///
|
||||
/// This was a hand-rolled `AnimatedContainer` with a gradient fill, a drop
|
||||
/// shadow, its own radius, its own label size and `disabledFill` used as the
|
||||
/// *text* colour when disabled. It is the same action as the button on the
|
||||
/// no-bonus branch fifty lines up — "move to the next stop" — so it is the
|
||||
/// same button now, and the celebration is carried by the screen around it
|
||||
/// rather than by giving the CTA its own costume.
|
||||
Widget _buildDoneButton({required bool isEnabled}) {
|
||||
final bool hasNext = _nextOrders.isNotEmpty;
|
||||
return MilerButton(
|
||||
label: hasNext ? 'Move to next stop' : 'Return to hub',
|
||||
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
|
||||
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
|
||||
onPressed: isEnabled ? () => _goToNextStop() : null,
|
||||
);
|
||||
}
|
||||
|
||||
Path drawStar(Size size) {
|
||||
double degToRad(double deg) => deg * (math.pi / 180.0);
|
||||
const numberOfPoints = 5;
|
||||
final halfWidth = size.width / 2;
|
||||
final externalRadius = halfWidth;
|
||||
final internalRadius = halfWidth / 2.5;
|
||||
final degreesPerStep = degToRad(360 / numberOfPoints);
|
||||
final halfDegreesPerStep = degreesPerStep / 2;
|
||||
final path = Path();
|
||||
final fullAngle = degToRad(360);
|
||||
path.moveTo(size.width, halfWidth);
|
||||
for (double step = 0; step < fullAngle; step += degreesPerStep) {
|
||||
path.lineTo(
|
||||
halfWidth + externalRadius * math.cos(step),
|
||||
halfWidth + externalRadius * math.sin(step),
|
||||
);
|
||||
path.lineTo(
|
||||
halfWidth + internalRadius * math.cos(step + halfDegreesPerStep),
|
||||
halfWidth + internalRadius * math.sin(step + halfDegreesPerStep),
|
||||
);
|
||||
}
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
1587
lib/views/Dashboard/pickups/map.dart
Normal file
1587
lib/views/Dashboard/pickups/map.dart
Normal file
File diff suppressed because it is too large
Load Diff
39
lib/views/Dashboard/pickups/map_btn.dart
Normal file
39
lib/views/Dashboard/pickups/map_btn.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
part of 'pickups.dart';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MAP VIEW BUTTON (Customer locations)
|
||||
// -------------------------------------------------------------------------
|
||||
class MapViewRow extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> pickup;
|
||||
final Map<String, int>? preservedStepNumbers;
|
||||
|
||||
const MapViewRow({
|
||||
super.key,
|
||||
required this.pickup,
|
||||
this.preservedStepNumbers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
openScreen(
|
||||
context,
|
||||
MultiCustomerMapScreen(
|
||||
pickup: pickup,
|
||||
preservedStepNumbers: preservedStepNumbers ?? {},
|
||||
),
|
||||
swipeToGoBack: false,
|
||||
);
|
||||
},
|
||||
child: Image.asset(
|
||||
'assets/images/customermap.png',
|
||||
color: ColorConstants.primary,
|
||||
height: 32,
|
||||
width: 32,
|
||||
errorBuilder: (c, e, s) =>
|
||||
Icon(Icons.map, size: 32, color: ColorConstants.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
878
lib/views/Dashboard/pickups/multi_map.dart
Normal file
878
lib/views/Dashboard/pickups/multi_map.dart
Normal file
@@ -0,0 +1,878 @@
|
||||
part of 'pickups.dart';
|
||||
|
||||
class _StepPoint {
|
||||
final int step;
|
||||
final LatLng position;
|
||||
_StepPoint(this.step, this.position);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MULTI CUSTOMER MAP (Shows all pickup with real step numbers)
|
||||
// -------------------------------------------------------------------------
|
||||
class MultiCustomerMapScreen extends StatefulWidget {
|
||||
final List<Map<String, dynamic>> pickup;
|
||||
final Map<String, int> preservedStepNumbers;
|
||||
|
||||
const MultiCustomerMapScreen({
|
||||
super.key,
|
||||
required this.pickup,
|
||||
this.preservedStepNumbers = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
State<MultiCustomerMapScreen> createState() => _MultiCustomerMapScreenState();
|
||||
}
|
||||
|
||||
class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final MapController mapController = MapController();
|
||||
late final MilerMapCamera _camera;
|
||||
List<Marker> markers = <Marker>[];
|
||||
|
||||
/// The route as known, faint, underneath.
|
||||
List<Polyline> casingLines = <Polyline>[];
|
||||
|
||||
/// The bright line drawing itself in over the casing. Named rather than kept
|
||||
/// in a set keyed by 'route_progress': `flutter_map` draws a list in order, so
|
||||
/// the order is the z-order and the id had nothing left to do.
|
||||
Polyline? progressLine;
|
||||
|
||||
List<Polyline> get routeLayers => <Polyline>[
|
||||
...casingLines,
|
||||
if (progressLine != null) progressLine!,
|
||||
];
|
||||
|
||||
/// Bumped whenever a route layer changes mid-animation, so only the map
|
||||
/// is rebuilt rather than the whole screen.
|
||||
final ValueNotifier<int> _mapRepaint = ValueNotifier<int>(0);
|
||||
LatLng? currentLocation;
|
||||
bool _isLoading = true;
|
||||
bool _mapReady = false;
|
||||
final Map<String, Map<String, dynamic>> _pickupMap = {};
|
||||
|
||||
/// Every routed point, hub to last stop, in riding order.
|
||||
///
|
||||
/// The segments are fetched one leg at a time, but the *animation* has to run
|
||||
/// across the whole route — drawing each leg in turn would restart at every
|
||||
/// stop and read as five separate lines rather than one journey.
|
||||
final List<LatLng> _fullRoute = [];
|
||||
|
||||
late final AnimationController _routeAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Long enough to read as a route being traced rather than a line appearing;
|
||||
// short enough that the rider is not waiting on it to see his stops.
|
||||
_camera = MilerMapCamera(controller: mapController, vsync: this);
|
||||
_routeAnim = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
)..addListener(_onRouteTick);
|
||||
if (widget.pickup.isNotEmpty) {
|
||||
final firstPickup = widget.pickup.first;
|
||||
final dropLat = _parseD(
|
||||
firstPickup['droplat'] ?? firstPickup['pickuplat'] ?? 0,
|
||||
);
|
||||
final dropLon = _parseD(
|
||||
firstPickup['droplon'] ?? firstPickup['pickuplong'] ?? 0,
|
||||
);
|
||||
if (dropLat != 0 && dropLon != 0) {
|
||||
currentLocation = LatLng(dropLat, dropLon);
|
||||
} else {
|
||||
currentLocation = const LatLng(11.018356, 77.012596);
|
||||
}
|
||||
} else {
|
||||
currentLocation = const LatLng(11.018356, 77.012596);
|
||||
}
|
||||
_loadMapData();
|
||||
}
|
||||
|
||||
double _parseD(dynamic v) {
|
||||
if (v == null) return 0.0;
|
||||
if (v is num) return v.toDouble();
|
||||
return double.tryParse(v.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
int _getStepNumber(Map<String, dynamic> Booking) {
|
||||
final dynamic raw = Booking['step'] ?? Booking['Step'];
|
||||
final int step = raw == null
|
||||
? 0
|
||||
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
|
||||
return step;
|
||||
}
|
||||
|
||||
String _getOrderKey(Map<String, dynamic> Booking) {
|
||||
final pickupId = (Booking['pickupid'] ?? '').toString();
|
||||
final orderId = (Booking['orderid'] ?? '').toString();
|
||||
return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
|
||||
}
|
||||
|
||||
int _getPreservedOrCurrentStep(Map<String, dynamic> Booking) {
|
||||
final orderKey = _getOrderKey(Booking);
|
||||
if (widget.preservedStepNumbers.containsKey(orderKey)) {
|
||||
return widget.preservedStepNumbers[orderKey]!;
|
||||
}
|
||||
final currentStep = _getStepNumber(Booking);
|
||||
return currentStep;
|
||||
}
|
||||
|
||||
Future<void> _loadMapData() async {
|
||||
try {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
_getCurrentLocation().then((_) {
|
||||
if (mounted) {
|
||||
_createPickupMarkers();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('[CUSTOMER_MAP] Error loading map data: $e');
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getCurrentLocation() async {
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
final lastPos = await Geolocator.getLastKnownPosition();
|
||||
if (lastPos != null && mounted) {
|
||||
setState(() {
|
||||
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
final lastPos = await Geolocator.getLastKnownPosition();
|
||||
if (lastPos != null && mounted) {
|
||||
setState(() {
|
||||
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Position? position;
|
||||
try {
|
||||
position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.medium,
|
||||
distanceFilter: 0,
|
||||
timeLimit: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[CUSTOMER_MAP] Timeout getting location, using last known: $e',
|
||||
);
|
||||
position = await Geolocator.getLastKnownPosition();
|
||||
}
|
||||
|
||||
final safePosition = position;
|
||||
if (safePosition != null && mounted) {
|
||||
setState(() {
|
||||
currentLocation = LatLng(
|
||||
safePosition.latitude,
|
||||
safePosition.longitude,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[CUSTOMER_MAP] Error getting location: $e');
|
||||
try {
|
||||
final lastPos = await Geolocator.getLastKnownPosition();
|
||||
if (lastPos != null && mounted) {
|
||||
setState(() {
|
||||
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
int? _getNextPickupStep() {
|
||||
// Find the first non-skipped pickup (next pickup from user location)
|
||||
for (int i = 0; i < widget.pickup.length; i++) {
|
||||
final pickup = widget.pickup[i];
|
||||
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
|
||||
.trim();
|
||||
if (status != 'skipped') {
|
||||
final stepNumber = _getPreservedOrCurrentStep(pickup);
|
||||
if (stepNumber > 0) {
|
||||
return stepNumber;
|
||||
} else {
|
||||
// Calculate display step for bookings without step
|
||||
final ordersWithStepBefore = widget.pickup
|
||||
.sublist(0, i)
|
||||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||||
.length;
|
||||
final totalOrdersWithStep = widget.pickup
|
||||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||||
.length;
|
||||
return totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _createPickupMarkers() async {
|
||||
final tempMarkers = <Marker>[];
|
||||
List<_StepPoint> stepPoints = [];
|
||||
_pickupMap.clear();
|
||||
|
||||
final nextStep = _getNextPickupStep();
|
||||
|
||||
for (int i = 0; i < widget.pickup.length; i++) {
|
||||
final pickup = widget.pickup[i];
|
||||
final stepNumber = _getPreservedOrCurrentStep(pickup);
|
||||
|
||||
int displayStep;
|
||||
if (stepNumber > 0) {
|
||||
displayStep = stepNumber;
|
||||
} else {
|
||||
final ordersWithStepBefore = widget.pickup
|
||||
.sublist(0, i)
|
||||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||||
.length;
|
||||
final totalOrdersWithStep = widget.pickup
|
||||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||||
.length;
|
||||
displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
|
||||
}
|
||||
|
||||
final double lat = _parseD(pickup['droplat'] ?? pickup['pickuplat']);
|
||||
final double lon = _parseD(pickup['droplon'] ?? pickup['pickuplong']);
|
||||
if (lat == 0 || lon == 0) continue;
|
||||
|
||||
final orderId = (pickup['orderid'] ?? '').toString();
|
||||
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
|
||||
.trim();
|
||||
final isSkipped = status == 'skipped';
|
||||
final isNext = displayStep == nextStep;
|
||||
|
||||
// Save pickup data for dialog
|
||||
_pickupMap['pickup_$orderId'] = pickup;
|
||||
|
||||
// ── A widget, not a bitmap ──
|
||||
//
|
||||
// This used to await `_createCircularMarkerBitmap`, a hundred lines that
|
||||
// opened a `PictureRecorder`, painted a circle and a number onto a canvas,
|
||||
// rasterised it to PNG bytes and handed the bytes to the platform — per
|
||||
// marker, per rebuild, asynchronously, because a Google Maps marker is a
|
||||
// texture and cannot contain a `Text`. A `flutter_map` marker is a widget,
|
||||
// so the number is just a number, in the app's own font.
|
||||
//
|
||||
// The tap that opened the customer sheet moves onto the widget too. The
|
||||
// `InfoWindow` that carried the name is gone with it: it only appeared on
|
||||
// tap, covered the route it was anchored to, and said what the sheet the
|
||||
// same tap opens says properly.
|
||||
tempMarkers.add(
|
||||
milerMarker(
|
||||
point: LatLng(lat, lon),
|
||||
width: MilerNumberPin.size + 8,
|
||||
height: MilerNumberPin.size + MilerNumberPin.tail,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showCustomerDetailsSheet(pickup, displayStep),
|
||||
child: MilerNumberPin(
|
||||
number: displayStep,
|
||||
color: isSkipped
|
||||
? ColorConstants.warning
|
||||
: isNext
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon)));
|
||||
}
|
||||
|
||||
if (currentLocation != null) {
|
||||
tempMarkers.add(
|
||||
milerMarker(
|
||||
point: currentLocation!,
|
||||
width: MilerRiderDot.size,
|
||||
height: MilerRiderDot.size,
|
||||
child: const MilerRiderDot(),
|
||||
),
|
||||
);
|
||||
stepPoints.insert(0, _StepPoint(0, currentLocation!));
|
||||
}
|
||||
|
||||
stepPoints.sort((a, b) => a.step.compareTo(b.step));
|
||||
|
||||
final newPolylines = <Polyline>[];
|
||||
_fullRoute.clear();
|
||||
|
||||
for (int i = 0; i < stepPoints.length - 1; i++) {
|
||||
final start = stepPoints[i].position;
|
||||
final end = stepPoints[i + 1].position;
|
||||
|
||||
// OSRM instead of Directions — same answer, no key. See [MilerRouter].
|
||||
final routePoints = await MilerRouter.route(start, end);
|
||||
|
||||
if (routePoints.isNotEmpty) {
|
||||
_fullRoute.addAll(routePoints);
|
||||
newPolylines.add(
|
||||
Polyline(
|
||||
points: routePoints,
|
||||
strokeWidth: 6,
|
||||
// The brand accent, matching the single-stop map. This was
|
||||
// `Colors.blue`, so the two map screens drew the same route in two
|
||||
// different colours.
|
||||
color: ColorConstants.primary,
|
||||
strokeCap: StrokeCap.round,
|
||||
strokeJoin: StrokeJoin.round,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// A leg the router could not resolve still has to enter the animated
|
||||
// path, or the trace stops dead at the first unroutable stop.
|
||||
_fullRoute.addAll([start, end]);
|
||||
newPolylines.add(
|
||||
Polyline(
|
||||
points: [start, end],
|
||||
strokeWidth: 4,
|
||||
// Straight-line fallback — lighter, because it is a guess at the
|
||||
// route rather than the route.
|
||||
color: ColorConstants.primary.withValues(alpha: 0.35),
|
||||
pattern: StrokePattern.dashed(segments: const [20, 12]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
markers = tempMarkers;
|
||||
// The resolved segments become the faint casing — the route as known —
|
||||
// and the bright line is then traced over them. Same two-layer treatment
|
||||
// the single-stop map uses, so the two screens animate identically.
|
||||
casingLines = [
|
||||
for (final p in newPolylines)
|
||||
Polyline(
|
||||
points: p.points,
|
||||
color: ColorConstants.primary.withValues(alpha: 0.18),
|
||||
strokeWidth: 9,
|
||||
pattern: p.pattern,
|
||||
strokeCap: StrokeCap.round,
|
||||
strokeJoin: StrokeJoin.round,
|
||||
),
|
||||
];
|
||||
});
|
||||
if (_fullRoute.length > 1) {
|
||||
_buildDistanceTable();
|
||||
_routeAnim.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
_fitBoundsToMarkersAndPolylines();
|
||||
}
|
||||
|
||||
/// Distance from the route's start to each point in [_fullRoute].
|
||||
///
|
||||
/// The animation walks this table rather than the point list, and that is the
|
||||
/// difference between the head moving and the head stuttering. A routing API
|
||||
/// puts vertices where the road bends, so a roundabout can carry twenty of
|
||||
/// them across thirty metres while a bypass carries two across a kilometre.
|
||||
/// Stepping by index spends equal time on each, which makes the head crawl
|
||||
/// through every junction and leap down every straight.
|
||||
List<double> _cumulative = <double>[];
|
||||
|
||||
void _buildDistanceTable() {
|
||||
_cumulative = List<double>.filled(_fullRoute.length, 0);
|
||||
if (_fullRoute.length < 2) return;
|
||||
|
||||
// Longitude degrees shrink with latitude, so scale them before measuring or
|
||||
// an east–west leg is over-weighted. Plane geometry is ample here: the table
|
||||
// only has to get the *ratios* between legs right.
|
||||
final meanLat =
|
||||
_fullRoute.map((p) => p.latitude).reduce((a, b) => a + b) /
|
||||
_fullRoute.length;
|
||||
final lonScale = math.cos(meanLat * math.pi / 180).abs().clamp(0.05, 1.0);
|
||||
|
||||
for (var i = 1; i < _fullRoute.length; i++) {
|
||||
final a = _fullRoute[i - 1], b = _fullRoute[i];
|
||||
final dy = b.latitude - a.latitude;
|
||||
final dx = (b.longitude - a.longitude) * lonScale;
|
||||
_cumulative[i] = _cumulative[i - 1] + math.sqrt(dy * dy + dx * dx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The point [f] of the way along the route *by length*, as a segment index
|
||||
/// plus a fraction into that segment.
|
||||
(int, double) _atDistance(double f) {
|
||||
final total = _cumulative.isEmpty ? 0.0 : _cumulative.last;
|
||||
if (total <= 0) return (0, 0);
|
||||
|
||||
final target = (f.clamp(0.0, 1.0)) * total;
|
||||
var lo = 0, hi = _cumulative.length - 1;
|
||||
while (lo < hi) {
|
||||
final mid = (lo + hi) >> 1;
|
||||
if (_cumulative[mid] <= target) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
final i = (lo - 1).clamp(0, _fullRoute.length - 2);
|
||||
final span = _cumulative[i + 1] - _cumulative[i];
|
||||
return (
|
||||
i,
|
||||
span <= 0 ? 0.0 : ((target - _cumulative[i]) / span).clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reveals the bright line progressively, with an interpolated leading tip.
|
||||
void _onRouteTick() {
|
||||
if (!mounted || _fullRoute.length < 2) return;
|
||||
if (_cumulative.length != _fullRoute.length) _buildDistanceTable();
|
||||
|
||||
final t = Curves.easeInOutCubic.transform(_routeAnim.value);
|
||||
final (idx, frac) = _atDistance(t);
|
||||
|
||||
final shown = <LatLng>[..._fullRoute.sublist(0, idx + 1)];
|
||||
if (idx < _fullRoute.length - 1) {
|
||||
final a = _fullRoute[idx];
|
||||
final b = _fullRoute[idx + 1];
|
||||
shown.add(
|
||||
LatLng(
|
||||
a.latitude + (b.latitude - a.latitude) * frac,
|
||||
a.longitude + (b.longitude - a.longitude) * frac,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
progressLine = Polyline(
|
||||
points: shown,
|
||||
color: ColorConstants.primary,
|
||||
strokeWidth: 6,
|
||||
strokeCap: StrokeCap.round,
|
||||
strokeJoin: StrokeJoin.round,
|
||||
);
|
||||
|
||||
// Only the map rebuilds — a `setState` here redrew the whole screen, sheet
|
||||
// and all, sixty times a second to move one line.
|
||||
_mapRepaint.value++;
|
||||
}
|
||||
|
||||
/// Frames every stop and every metre of route.
|
||||
///
|
||||
/// The retry-with-delay dance this replaces existed because
|
||||
/// `GoogleMapController.animateCamera` threw until the platform view had
|
||||
/// finished creating itself. `flutter_map`'s camera is available as soon as
|
||||
/// the map is in the tree.
|
||||
Future<void> _fitBoundsToMarkersAndPolylines() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final points = <LatLng>[
|
||||
for (final m in markers) m.point,
|
||||
for (final poly in routeLayers) ...poly.points,
|
||||
];
|
||||
if (points.isEmpty) return;
|
||||
|
||||
await _camera.fit(points, padding: const EdgeInsets.all(64));
|
||||
}
|
||||
|
||||
void _showCustomerDetailsSheet(Map<String, dynamic> pickup, int stepNumber) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
sheetAnimationStyle: kMilerSheetStyle,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
enableDrag: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
final customerName = (pickup['pickupcustomer'] ?? 'Customer')
|
||||
.toString();
|
||||
final address = (pickup['pickupaddress'] ?? 'Address not available')
|
||||
.toString();
|
||||
final phone = (pickup['pickupcontactno'] ?? '').toString();
|
||||
final orderId = (pickup['orderid'] ?? '').toString();
|
||||
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
|
||||
.trim();
|
||||
final isSkipped = status == 'skipped';
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: true,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with title and close icon
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Customer Details',
|
||||
style: TextStyle(
|
||||
fontSize: 22.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.dividerColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
// A close affordance, not a destructive action.
|
||||
// This was a 34px red `Icons.cancel` — the loudest
|
||||
// thing in the sheet, competing with the call button
|
||||
// and reading as "delete this stop".
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Divider(thickness: 1.5),
|
||||
|
||||
// Step number and status
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSkipped
|
||||
? ColorConstants.warning
|
||||
: ColorConstants.primary,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Text(
|
||||
'Step $stepNumber',
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSkipped) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.warning,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Text(
|
||||
'SKIPPED',
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Customer name
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
size: 20,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Customer Name',
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 16.sp,
|
||||
color: ColorConstants.slateText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
customerName,
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Pickup address
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Recipient Address',
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 16.sp,
|
||||
color: ColorConstants.slateText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
address,
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Phone number
|
||||
if (phone.isNotEmpty)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
size: 20,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Contact Number',
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 16.sp,
|
||||
color: ColorConstants.slateText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
phone,
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Booking ID
|
||||
Text(
|
||||
'Booking ID: $orderId',
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 16.sp,
|
||||
color: ColorConstants.slateText,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Call button
|
||||
MilerButton(
|
||||
label: 'Call',
|
||||
icon: Icons.phone,
|
||||
color: ColorConstants.acceptGreen,
|
||||
onPressed: () async {
|
||||
// Close bottom sheet first
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Small delay to ensure bottom sheet is closed
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
// Then make the call
|
||||
final bool success = await launchPhoneDialer(phone);
|
||||
if (!success && context.mounted) {
|
||||
AppFeedback.error(context, 'Could not open the dialer');
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// The class had no dispose before the route animation was added — a ticker
|
||||
// outliving its State keeps the whole subtree alive and fires into a widget
|
||||
// that is no longer mounted.
|
||||
_routeAnim.dispose();
|
||||
_mapRepaint.dispose();
|
||||
_camera.dispose();
|
||||
mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
LatLng initialTarget;
|
||||
double initialZoom = 13;
|
||||
|
||||
if (currentLocation != null) {
|
||||
initialTarget = currentLocation!;
|
||||
} else if (widget.pickup.isNotEmpty) {
|
||||
final firstPickup = widget.pickup.first;
|
||||
final lat = _parseD(firstPickup['droplat'] ?? firstPickup['pickuplat']);
|
||||
final lon = _parseD(firstPickup['droplon'] ?? firstPickup['pickuplong']);
|
||||
initialTarget = (lat != 0 && lon != 0)
|
||||
? LatLng(lat, lon)
|
||||
: const LatLng(11.0168, 76.9558);
|
||||
} else {
|
||||
initialTarget = const LatLng(11.0168, 76.9558);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
// ── A light app bar, not a red slab ──
|
||||
//
|
||||
// This was a solid brand-red bar with white text, 80pt tall, built out of
|
||||
// a PreferredSize wrapping an AppBar whose `leading` was an infinitely
|
||||
// wide Row holding both the back arrow and the title. Two problems: a
|
||||
// saturated red band across the top of a map competes with the route drawn
|
||||
// on it — the map is the content, and the chrome was louder than it — and
|
||||
// every other pushed screen in the app has a white bar, so this one read
|
||||
// as a different app.
|
||||
//
|
||||
// Inverted, and rebuilt as an ordinary AppBar: white surface, brand red on
|
||||
// the title where it costs nothing, a real `leading` slot, and the same
|
||||
// hairline the rest of the app uses to close a bar.
|
||||
appBar: AppBar(
|
||||
// The app-wide frosted brand wash — see [milerGlassSurface].
|
||||
backgroundColor: Colors.transparent,
|
||||
flexibleSpace: milerGlassSurface(),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
titleSpacing: 0,
|
||||
leading: IconButton(
|
||||
tooltip: 'Back',
|
||||
icon: Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
color: ColorConstants.slateText,
|
||||
size: 22.sp,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
'Route map',
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 19.sp,
|
||||
color: ColorConstants.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: AfterEntrance(
|
||||
placeholder: const MapPlaceholder(),
|
||||
builder: (context) => ValueListenableBuilder<int>(
|
||||
valueListenable: _mapRepaint,
|
||||
builder: (context, _, _) => MilerMap(
|
||||
controller: mapController,
|
||||
initialCenter: initialTarget,
|
||||
initialZoom: initialZoom,
|
||||
polylines: routeLayers,
|
||||
markers: markers,
|
||||
onReady: () {
|
||||
_mapReady = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_mapReady) _fitBoundsToMarkersAndPolylines();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
853
lib/views/Dashboard/pickups/payment_screen.dart
Normal file
853
lib/views/Dashboard/pickups/payment_screen.dart
Normal file
@@ -0,0 +1,853 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart';
|
||||
import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart';
|
||||
|
||||
class PaymentScreen extends StatefulWidget {
|
||||
final Map<String, dynamic> pickup;
|
||||
final Map<String, dynamic> verificationData;
|
||||
|
||||
const PaymentScreen({
|
||||
super.key,
|
||||
required this.pickup,
|
||||
required this.verificationData,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentScreen> createState() => _PaymentScreenState();
|
||||
}
|
||||
|
||||
class _PaymentScreenState extends State<PaymentScreen>
|
||||
with TickerProviderStateMixin {
|
||||
String _paymentMethod = 'cash';
|
||||
bool _isProcessing = false;
|
||||
bool _isSuccess = false;
|
||||
bool _paymentVerified = false;
|
||||
double _pickupCharge = 0.0;
|
||||
double _totalAmount = 0.0;
|
||||
|
||||
late final String _customerName;
|
||||
late final String _orderId;
|
||||
late final String _bookingId;
|
||||
late final String _trackingId;
|
||||
late final String _weight;
|
||||
late final String _description;
|
||||
|
||||
late AnimationController _successAnimController;
|
||||
late Animation<double> _successScaleAnim;
|
||||
late Animation<double> _successFadeAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final d = widget.pickup;
|
||||
final v = widget.verificationData;
|
||||
_customerName = (d['pickupcustomer'] ?? 'Customer').toString();
|
||||
_orderId = (d['orderid'] ?? '').toString();
|
||||
_bookingId = _orderId.isNotEmpty
|
||||
? _orderId
|
||||
: 'DM${DateTime.now().millisecondsSinceEpoch.toString().substring(5)}';
|
||||
_trackingId =
|
||||
(d['trackingid'] ??
|
||||
'TRK${DateTime.now().millisecondsSinceEpoch.toString().substring(4)}')
|
||||
.toString();
|
||||
_weight = (v['weight'] ?? '0').toString();
|
||||
_description = (v['description'] ?? '').toString();
|
||||
_pickupCharge = double.tryParse(_weight) != null
|
||||
? double.parse(_weight) * 15.0
|
||||
: 30.0;
|
||||
_totalAmount = _pickupCharge;
|
||||
|
||||
_successAnimController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
_successScaleAnim = CurvedAnimation(
|
||||
parent: _successAnimController,
|
||||
curve: Curves.elasticOut,
|
||||
);
|
||||
_successFadeAnim = CurvedAnimation(
|
||||
parent: _successAnimController,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_successAnimController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _processPayment() async {
|
||||
if (_isProcessing) return;
|
||||
setState(() => _isProcessing = true);
|
||||
|
||||
// Simulate backend payment verification
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Verify payment was successful
|
||||
_paymentVerified = true;
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_isSuccess = true;
|
||||
});
|
||||
|
||||
// Start success animation
|
||||
_successAnimController.forward();
|
||||
|
||||
// Auto-navigate after showing success (optional)
|
||||
await Future.delayed(const Duration(milliseconds: 2500));
|
||||
if (mounted && _isSuccess) {
|
||||
_completePayment();
|
||||
}
|
||||
}
|
||||
|
||||
void _completePayment({bool goToBookings = false}) {
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, {
|
||||
'paid': true,
|
||||
'goto_bookings': goToBookings,
|
||||
'verified': true,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isSuccess) return _buildSuccessView();
|
||||
return _buildPaymentView();
|
||||
}
|
||||
|
||||
// ────────────────────────────── SUCCESS VIEW ─────────────────────────────
|
||||
Widget _buildSuccessView() {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
body: SafeArea(
|
||||
child: FadeTransition(
|
||||
opacity: _successFadeAnim,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.r),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Success animation circle
|
||||
ScaleTransition(
|
||||
scale: _successScaleAnim,
|
||||
child: Container(
|
||||
width: 110.r,
|
||||
height: 110.r,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
ColorConstants.success,
|
||||
ColorConstants.acceptGreen,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorConstants.success.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
color: Colors.white,
|
||||
size: 52.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
Text(
|
||||
'Payment Collected Successfully',
|
||||
style: TextStyle(
|
||||
fontSize: 26.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'₹${_totalAmount.toStringAsFixed(0)} via ${_paymentMethod.toUpperCase()}',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
|
||||
// Receipt Card - Corporate Design
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusXl,
|
||||
),
|
||||
border: Border.all(color: ColorConstants.outlineVariant),
|
||||
boxShadow: DesignConstants.shadowSm,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Receipt header
|
||||
Container(
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
ColorConstants.primary.withValues(alpha: 0.08),
|
||||
ColorConstants.primary.withValues(alpha: 0.03),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(18.r),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(10.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primaryContainer
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.receipt_long_rounded,
|
||||
color: ColorConstants.primary,
|
||||
size: 18.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Payment Receipt',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Payment verified and recorded',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(16.r),
|
||||
child: Column(
|
||||
children: [
|
||||
_receiptRow('Booking ID', '#$_bookingId'),
|
||||
SizedBox(height: 10.h),
|
||||
Divider(
|
||||
color: ColorConstants.outlineVariant,
|
||||
height: 1,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_receiptRow('Tracking ID', _trackingId),
|
||||
SizedBox(height: 10.h),
|
||||
Divider(
|
||||
color: ColorConstants.outlineVariant,
|
||||
height: 1,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_receiptRow('Customer', _customerName),
|
||||
SizedBox(height: 10.h),
|
||||
Divider(
|
||||
color: ColorConstants.outlineVariant,
|
||||
height: 1,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_receiptRow(
|
||||
'Amount Collected',
|
||||
'₹${_totalAmount.toStringAsFixed(0)}',
|
||||
valueColor: ColorConstants.success,
|
||||
valueBold: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Action Buttons
|
||||
Column(
|
||||
children: [
|
||||
// Next Booking Button
|
||||
MilerButton(
|
||||
label: 'Move to next booking',
|
||||
onPressed: () => _completePayment(goToBookings: true),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
TextButton(
|
||||
onPressed: () => _completePayment(goToBookings: false),
|
||||
child: Text(
|
||||
'Return to Collection',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _receiptRow(
|
||||
String label,
|
||||
String value, {
|
||||
Color? valueColor,
|
||||
bool valueBold = false,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: valueBold ? FontWeight.w700 : FontWeight.w600,
|
||||
color: valueColor ?? ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────── PAYMENT VIEW ─────────────────────────────
|
||||
Widget _buildPaymentView() {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Premium Gradient Header ──────────────────────────────────
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
ColorConstants.primary,
|
||||
ColorConstants.primaryContainer,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
// App bar row
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 8.h,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 40.w,
|
||||
height: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface.withValues(
|
||||
alpha: 0.15,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: Colors.white,
|
||||
size: 16.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Text(
|
||||
'Payment Collection',
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Amount display
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 24.h),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Amount to Collect',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'₹${_totalAmount.toStringAsFixed(0)}',
|
||||
style: TextStyle(
|
||||
fontSize: 36.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 8.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface.withValues(
|
||||
alpha: 0.15,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'#$_orderId',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_customerName,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Scrollable body ──────────────────────────────────────────
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.all(16.r),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 4.h),
|
||||
|
||||
// ── Booking Summary Card ──────────────────────────────
|
||||
_sectionLabel('Booking Summary'),
|
||||
SizedBox(height: 10.h),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: _cardDecoration(),
|
||||
child: Column(
|
||||
children: [
|
||||
_summaryRow('Customer', _customerName),
|
||||
_divider(),
|
||||
_summaryRow('Booking', '#$_orderId'),
|
||||
_divider(),
|
||||
_summaryRow('Weight', '$_weight kg'),
|
||||
if (_description.isNotEmpty) ...[
|
||||
_divider(),
|
||||
_summaryRow('Item', _description),
|
||||
],
|
||||
_divider(),
|
||||
_summaryRow(
|
||||
'Collection Charge',
|
||||
'₹${_pickupCharge.toStringAsFixed(2)}',
|
||||
isBold: true,
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.h),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: ColorConstants.primary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
_summaryRow(
|
||||
'Total',
|
||||
'₹${_totalAmount.toStringAsFixed(2)}',
|
||||
isBold: true,
|
||||
isTotal: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// ── Payment Method ──────────────────────────────────
|
||||
_sectionLabel('Payment Method'),
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
_buildPaymentOption(
|
||||
Icons.money_rounded,
|
||||
'Cash',
|
||||
'Pay with cash on collection',
|
||||
'cash',
|
||||
ColorConstants.success,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_buildPaymentOption(
|
||||
Icons.qr_code_scanner_rounded,
|
||||
'QR / UPI',
|
||||
'Scan QR code to pay',
|
||||
'upi',
|
||||
ColorConstants.upiPurple,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_buildPaymentOption(
|
||||
Icons.credit_card_rounded,
|
||||
'Card',
|
||||
'Swipe / tap card terminal',
|
||||
'card',
|
||||
ColorConstants.tertiary,
|
||||
),
|
||||
|
||||
SizedBox(height: 28.h),
|
||||
|
||||
// ── Slide to Collect Button ─────────────────────────
|
||||
_isProcessing
|
||||
? Container(
|
||||
height: ButtonSizes.primary,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
child: Container(
|
||||
color: ColorConstants.primary,
|
||||
child: SliderButton(
|
||||
properties: SliderButtonProperties(
|
||||
radius: 24,
|
||||
height: ButtonSizes.primary,
|
||||
buttonSize: 48,
|
||||
width: MediaQuery.of(context).size.width - 32.w,
|
||||
backgroundColor: Colors.transparent,
|
||||
dismissThresholds: 0.85,
|
||||
action: () async {
|
||||
debugPrint(
|
||||
'[PAYMENT_SLIDER] Action called, processing payment',
|
||||
);
|
||||
await _processPayment();
|
||||
debugPrint(
|
||||
'[PAYMENT_SLIDER] _processPayment completed, returning true',
|
||||
);
|
||||
return true;
|
||||
},
|
||||
label: Text(
|
||||
'Slide to Collect ₹${_totalAmount.toStringAsFixed(0)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
icon: Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.payments_rounded,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
Center(
|
||||
child: Text(
|
||||
'Collect from customer before starting pickup',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(String label) {
|
||||
return Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentOption(
|
||||
IconData icon,
|
||||
String title,
|
||||
String subtitle,
|
||||
String value,
|
||||
Color accentColor,
|
||||
) {
|
||||
final isSelected = _paymentMethod == value;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _paymentMethod = value),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(14.r),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? accentColor.withValues(alpha: 0.06)
|
||||
: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(
|
||||
color: isSelected ? accentColor : ColorConstants.outlineVariant,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: accentColor.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: EdgeInsets.all(10.r),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? accentColor.withValues(alpha: 0.12)
|
||||
: const Color(0xFFF3F4F6),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? accentColor
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
size: 22.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 22.w,
|
||||
height: 22.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected ? accentColor : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected ? accentColor : const Color(0xFFD1D5DB),
|
||||
width: isSelected ? 0 : 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check_rounded, color: Colors.white, size: 14.sp)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryRow(
|
||||
String label,
|
||||
String value, {
|
||||
bool isBold = false,
|
||||
bool isTotal = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 5.h),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 15.sp : 13.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontWeight: isBold ? FontWeight.w600 : FontWeight.normal,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 17.sp : 14.sp,
|
||||
fontWeight: isTotal
|
||||
? FontWeight.w800
|
||||
: (isBold ? FontWeight.w700 : FontWeight.w500),
|
||||
color: isTotal
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _divider() => Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2.h),
|
||||
child: Divider(color: ColorConstants.outlineVariant, height: 1),
|
||||
);
|
||||
|
||||
BoxDecoration _cardDecoration() {
|
||||
return BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.outlineVariant),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
908
lib/views/Dashboard/pickups/pickup_verify.dart
Normal file
908
lib/views/Dashboard/pickups/pickup_verify.dart
Normal file
@@ -0,0 +1,908 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart';
|
||||
import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart';
|
||||
|
||||
class PickupVerificationPage extends StatefulWidget {
|
||||
final Map<String, dynamic> pickup;
|
||||
const PickupVerificationPage({super.key, required this.pickup});
|
||||
|
||||
@override
|
||||
State<PickupVerificationPage> createState() => _PickupVerificationPageState();
|
||||
}
|
||||
|
||||
class _PickupVerificationPageState extends State<PickupVerificationPage>
|
||||
with TickerProviderStateMixin {
|
||||
File? _parcelImage;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
bool _isConfirming = false;
|
||||
bool _photoVerified = false;
|
||||
|
||||
// Form controllers
|
||||
final TextEditingController _weightController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
final TextEditingController _otpController = TextEditingController();
|
||||
final TextEditingController _dropAddressController = TextEditingController();
|
||||
|
||||
late final String _customerName;
|
||||
late final String _pickupAddress;
|
||||
late final String _orderId;
|
||||
late final String _dropAddressDefault;
|
||||
|
||||
// Delivery stops (Amazon/Flipkart-style) verify differently: proof is
|
||||
// OTP + photo (prepaid, no weight/signature). Pickups keep the full form.
|
||||
late final bool _isDelivery;
|
||||
|
||||
late AnimationController _fadeController;
|
||||
late Animation<double> _fadeAnim;
|
||||
|
||||
// Red Theme Colors
|
||||
static Color get _primaryRed => ColorConstants.primary;
|
||||
static Color get _darkRed => ColorConstants.primary;
|
||||
static Color get _lightRed => ColorConstants.errorLight;
|
||||
static Color get _successGreen => ColorConstants.acceptGreen;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final d = widget.pickup;
|
||||
_customerName = (d['PickupCustomer'] ?? d['pickupcustomer'] ?? 'Customer')
|
||||
.toString();
|
||||
_pickupAddress =
|
||||
(d['pickupaddress'] ?? d['PickupAddress'] ?? 'Pickup Location')
|
||||
.toString();
|
||||
_orderId = (d['orderid'] ?? '').toString();
|
||||
_dropAddressDefault =
|
||||
(d['DropAddress'] ?? d['dropaddress'] ?? 'Drop Location').toString();
|
||||
_dropAddressController.text = _dropAddressDefault;
|
||||
_isDelivery = stopKindOf(d).isDelivery;
|
||||
|
||||
_fadeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeAnim = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut);
|
||||
_fadeController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_weightController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_otpController.dispose();
|
||||
_dropAddressController.dispose();
|
||||
_fadeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _takeParcelPhoto() async {
|
||||
try {
|
||||
final XFile? photo = await _picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (photo != null && mounted) {
|
||||
setState(() {
|
||||
_parcelImage = File(photo.path);
|
||||
_photoVerified = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to capture photo')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Required proof per stop is kept light: Photo + Code (pickup) / Photo + OTP
|
||||
// (delivery). Weight is optional — no keyboard entry needed at every doorstep.
|
||||
bool get _isFormValid => _isDelivery
|
||||
? (_photoVerified && _otpController.text.trim().isNotEmpty)
|
||||
: (_photoVerified && _otpController.text.trim().isNotEmpty);
|
||||
|
||||
void _confirmPickup() {
|
||||
if (!_isFormValid) {
|
||||
String msg = 'Please complete all required fields';
|
||||
if (!_photoVerified) {
|
||||
msg = _isDelivery
|
||||
? 'Take a photo of the delivered parcel first'
|
||||
: 'Take a photo of the parcel first';
|
||||
} else if (_isDelivery && _otpController.text.trim().isEmpty) {
|
||||
msg = 'Enter the delivery OTP from the customer';
|
||||
} else if (!_isDelivery && _otpController.text.trim().isEmpty) {
|
||||
msg = 'Enter the pickup code from the customer';
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: _primaryRed,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isConfirming = true);
|
||||
|
||||
final verified = {
|
||||
'verified': true,
|
||||
'parcelImage': _parcelImage?.path,
|
||||
'weight': _weightController.text.trim(),
|
||||
'description': _descriptionController.text.trim(),
|
||||
'dropAddress': _dropAddressController.text.trim(),
|
||||
'otp': _otpController.text.trim(),
|
||||
};
|
||||
|
||||
debugPrint(
|
||||
'[PICKUP_VERIFY] Confirming pickup and popping with verified data',
|
||||
);
|
||||
|
||||
// Pop with verified data
|
||||
Navigator.pop(context, verified);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.daylightSurface,
|
||||
appBar: _buildAppBar(),
|
||||
body: FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 32.h),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width - 32.w,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildProgressSteps(),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
_buildCustomerCard(),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
_buildSectionHeader(
|
||||
icon: Icons.camera_alt_rounded,
|
||||
title: _isDelivery ? 'Delivery Photo' : 'Parcel Photo',
|
||||
badge: 'Required',
|
||||
isComplete: _photoVerified,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_buildPhotoCapture(),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// Weight is pickup-only. Deliveries are prepaid and proven by
|
||||
// OTP + photo. Signature was dropped — photo is enough proof for
|
||||
// a first-mile pickup, so it was pure friction on every stop.
|
||||
if (!_isDelivery) ...[
|
||||
_buildSectionHeader(
|
||||
icon: Icons.inventory_2_rounded,
|
||||
title: 'Parcel Details (Optional)',
|
||||
isComplete: _weightController.text.isNotEmpty,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_buildParcelDetailsCard(),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
|
||||
_buildSectionHeader(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
title: _isDelivery ? 'Delivery OTP' : 'Pickup Code',
|
||||
badge: 'Required',
|
||||
isComplete: _otpController.text.isNotEmpty,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_buildCard([
|
||||
TextField(
|
||||
controller: _otpController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration:
|
||||
_inputDecoration(
|
||||
_isDelivery
|
||||
? 'Enter OTP from customer'
|
||||
: 'Enter code from customer',
|
||||
).copyWith(
|
||||
counterText: '',
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
color: ColorConstants.secondaryText,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
]),
|
||||
SizedBox(height: 32.h),
|
||||
|
||||
_buildConfirmSlider(),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppBar _buildAppBar() {
|
||||
return AppBar(
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
// 56 → 68, the same air the tab bars gained. Height rather than a title
|
||||
// inset so the back arrow moves down with the heading instead of the two
|
||||
// drifting apart.
|
||||
toolbarHeight: 68,
|
||||
leading: IconButton(
|
||||
tooltip: 'Back',
|
||||
icon: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: ColorConstants.onSurface,
|
||||
size: 20.sp,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
// No brand mark. It is gone from every bar in the app: on a pushed
|
||||
// sub-page the back arrow beside it already says where the rider is,
|
||||
// and the logo was repeating what the screen he came from said.
|
||||
Text(
|
||||
_isDelivery ? 'Verify Delivery' : 'Verify Pickup',
|
||||
style: TextStyle(
|
||||
fontSize: 21.sp,
|
||||
color: ColorConstants.onSurface,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(height: 1, color: ColorConstants.dividerColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressSteps() {
|
||||
final steps = _isDelivery
|
||||
? [
|
||||
{'label': 'Photo', 'done': _photoVerified},
|
||||
{'label': 'OTP', 'done': _otpController.text.isNotEmpty},
|
||||
]
|
||||
: [
|
||||
{'label': 'Photo', 'done': _photoVerified},
|
||||
{'label': 'Code', 'done': _otpController.text.isNotEmpty},
|
||||
];
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.dividerColor),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(steps.length * 2 - 1, (i) {
|
||||
if (i.isOdd) {
|
||||
final leftDone = steps[i ~/ 2]['done'] as bool;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 2.h,
|
||||
margin: EdgeInsets.symmetric(horizontal: 4.w),
|
||||
decoration: BoxDecoration(
|
||||
color: leftDone ? _successGreen : ColorConstants.dividerColor,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final idx = i ~/ 2;
|
||||
final step = steps[idx];
|
||||
final done = step['done'] as bool;
|
||||
final label = step['label'] as String;
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: 32.w,
|
||||
height: 32.w,
|
||||
decoration: BoxDecoration(
|
||||
color: done ? _successGreen : ColorConstants.neutralLight,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: done ? _successGreen : ColorConstants.dividerColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
done ? Icons.check_rounded : Icons.circle_outlined,
|
||||
color: done ? Colors.white : ColorConstants.secondaryText,
|
||||
size: 16.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
fontWeight: done ? FontWeight.w700 : FontWeight.w500,
|
||||
color: done
|
||||
? _successGreen
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomerCard() {
|
||||
final initials = _customerName.trim().isNotEmpty
|
||||
? _customerName
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((w) => w.isNotEmpty ? w[0] : '')
|
||||
.take(2)
|
||||
.join()
|
||||
.toUpperCase()
|
||||
: 'C';
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [_primaryRed, _darkRed],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _primaryRed.withValues(alpha: 0.25),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44.w,
|
||||
height: 44.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_customerName,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
'Booking #$_orderId',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Container(
|
||||
height: 1,
|
||||
color: ColorConstants.pureSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
_buildLocationRow(
|
||||
Icons.location_on_rounded,
|
||||
_pickupAddress,
|
||||
_isDelivery ? 'DELIVERY' : 'PICKUP',
|
||||
ColorConstants.warning.withValues(alpha: 0.40),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationRow(
|
||||
IconData icon,
|
||||
String address,
|
||||
String label,
|
||||
Color accentColor,
|
||||
) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(5.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(icon, size: 14.sp, color: accentColor),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 9.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: accentColor,
|
||||
letterSpacing: 0.8,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 1.h),
|
||||
Text(
|
||||
address,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? badge,
|
||||
bool isComplete = false,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(7.r),
|
||||
decoration: BoxDecoration(
|
||||
color: isComplete
|
||||
? ColorConstants.successLight
|
||||
: ColorConstants.neutralLight,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(
|
||||
isComplete ? Icons.check_circle_rounded : icon,
|
||||
size: 16.sp,
|
||||
color: isComplete ? _successGreen : ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (badge != null)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: _lightRed,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Text(
|
||||
badge,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _primaryRed,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoCapture() {
|
||||
return GestureDetector(
|
||||
onTap: _takeParcelPhoto,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: double.infinity,
|
||||
height: 180.h,
|
||||
decoration: BoxDecoration(
|
||||
color: _photoVerified
|
||||
? Colors.transparent
|
||||
: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(
|
||||
color: _photoVerified ? _successGreen : ColorConstants.borderSubtle,
|
||||
width: _photoVerified ? 2 : 1,
|
||||
),
|
||||
boxShadow: _photoVerified
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _successGreen.withValues(alpha: 0.1),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: _parcelImage != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.file(_parcelImage!, fit: BoxFit.cover),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 10.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.6),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.white,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Text(
|
||||
'Photo captured — tap to retake',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 10.h,
|
||||
right: 10.w,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(6.r),
|
||||
decoration: BoxDecoration(
|
||||
color: _successGreen,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
color: Colors.white,
|
||||
size: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(18.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.camera_alt_rounded,
|
||||
color: ColorConstants.secondaryText,
|
||||
size: 36.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
'Tap to take parcel photo',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'Required for pickup confirmation',
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParcelDetailsCard() {
|
||||
return _buildCard([
|
||||
_fieldLabel('Weight (kg)'),
|
||||
SizedBox(height: 6.h),
|
||||
TextField(
|
||||
controller: _weightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: _inputDecoration('e.g. 2.5').copyWith(
|
||||
suffixText: 'kg',
|
||||
suffixStyle: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
style: TextStyle(fontSize: 15.sp, fontFamily: FontConstants.fontFamily),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
_fieldLabel('Description (Optional)'),
|
||||
SizedBox(height: 6.h),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 3,
|
||||
decoration: _inputDecoration('Item description, fragile, etc.'),
|
||||
style: TextStyle(fontSize: 15.sp, fontFamily: FontConstants.fontFamily),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _fieldLabel(String label, {bool required = false}) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
if (required) ...[
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
'*',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: _primaryRed,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmSlider() {
|
||||
if (_isConfirming) {
|
||||
return Container(
|
||||
height: ButtonSizes.primary,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_isFormValid) {
|
||||
return Container(
|
||||
height: ButtonSizes.primary,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.dividerColor,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
color: ColorConstants.secondaryText,
|
||||
size: 18.sp,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
!_photoVerified
|
||||
? 'Take a photo to continue'
|
||||
: _otpController.text.isEmpty
|
||||
? (_isDelivery
|
||||
? 'Enter OTP to continue'
|
||||
: 'Enter pickup code to continue')
|
||||
: 'Complete all fields to continue',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliderButton(
|
||||
properties: SliderButtonProperties(
|
||||
height: ButtonSizes.primary,
|
||||
buttonSize: 48,
|
||||
width: MediaQuery.of(context).size.width - 32.w,
|
||||
backgroundColor: ColorConstants.primary,
|
||||
dismissThresholds: 0.85,
|
||||
action: () async {
|
||||
debugPrint(
|
||||
'[PICKUP_VERIFY_SLIDER] Action called, form valid: $_isFormValid',
|
||||
);
|
||||
if (!_isFormValid) {
|
||||
debugPrint(
|
||||
'[PICKUP_VERIFY_SLIDER] Form not valid, returning false',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_confirmPickup();
|
||||
debugPrint(
|
||||
'[PICKUP_VERIFY_SLIDER] _confirmPickup called, returning true',
|
||||
);
|
||||
return true;
|
||||
},
|
||||
label: Text(
|
||||
'Slide to Confirm Pickup',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
icon: Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(List<Widget> children) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.borderSubtle),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) {
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: ColorConstants.daylightSurface,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
borderSide: BorderSide(color: ColorConstants.borderStrong),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
borderSide: BorderSide(color: ColorConstants.borderStrong),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
borderSide: BorderSide(color: ColorConstants.primary, width: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2506
lib/views/Dashboard/pickups/pickups.dart
Normal file
2506
lib/views/Dashboard/pickups/pickups.dart
Normal file
File diff suppressed because it is too large
Load Diff
173
lib/views/Dashboard/pickups/pip.dart
Normal file
173
lib/views/Dashboard/pickups/pip.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
part of 'pickups.dart';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PIP INFO CARD (Shown when PiP mode is enabled)
|
||||
// -------------------------------------------------------------------------
|
||||
class PipInfoCard extends StatefulWidget {
|
||||
final String orderId;
|
||||
final int etaMinutes; // ETA in minutes from API (always treat as minutes)
|
||||
|
||||
const PipInfoCard({
|
||||
super.key,
|
||||
required this.orderId,
|
||||
required this.etaMinutes,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PipInfoCard> createState() => _PipInfoCardState();
|
||||
}
|
||||
|
||||
class _PipInfoCardState extends State<PipInfoCard> {
|
||||
late final CountDownController _controller;
|
||||
int _durationSeconds = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = CountDownController();
|
||||
// Load remaining ETA from SharedPreferences so timer doesn't reset
|
||||
_initDuration();
|
||||
}
|
||||
|
||||
Future<void> _initDuration() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final endKey = 'eta_endtime_${widget.orderId}';
|
||||
final endSeconds = prefs.getInt(endKey);
|
||||
final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
int remaining = 0;
|
||||
if (endSeconds != null && endSeconds > nowSeconds) {
|
||||
remaining = endSeconds - nowSeconds;
|
||||
} else {
|
||||
// Fallback: use full ETA from API
|
||||
final int safeEtaMinutes = widget.etaMinutes < 0
|
||||
? 0
|
||||
: widget.etaMinutes;
|
||||
remaining = safeEtaMinutes * 60;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_durationSeconds = remaining.clamp(0, 24 * 60 * 60);
|
||||
});
|
||||
} catch (_) {
|
||||
// In case of error, just fall back to raw ETA minutes
|
||||
final int safeEtaMinutes = widget.etaMinutes < 0 ? 0 : widget.etaMinutes;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_durationSeconds = safeEtaMinutes * 60;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
// Expanded PiP: timer + key pickup details, but still relatively compact
|
||||
body: Center(
|
||||
child: widget.orderId.isEmpty || widget.orderId == 'N/A'
|
||||
? Text(
|
||||
'No Active Booking',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
)
|
||||
: Card(
|
||||
color: Colors.white,
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Timer
|
||||
SizedBox(
|
||||
width: 72,
|
||||
height: 72,
|
||||
child: _durationSeconds <= 0
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: ColorConstants.primary,
|
||||
width: 3,
|
||||
),
|
||||
color: Colors.white,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'Out',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
)
|
||||
: CircularCountDownTimer(
|
||||
duration: _durationSeconds,
|
||||
initialDuration: 0,
|
||||
controller: _controller,
|
||||
width: 72,
|
||||
height: 72,
|
||||
ringColor: ColorConstants.primary.withValues(
|
||||
alpha: 0.15,
|
||||
),
|
||||
fillColor: ColorConstants.primary,
|
||||
backgroundColor: Colors.white,
|
||||
strokeWidth: 5,
|
||||
strokeCap: StrokeCap.round,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
isReverse: true,
|
||||
isReverseAnimation: true,
|
||||
isTimerTextShown: true,
|
||||
autoStart: true,
|
||||
timeFormatterFunction:
|
||||
(defaultFormatter, duration) {
|
||||
return duration.inSeconds <= 0
|
||||
? 'Out'
|
||||
: defaultFormatter(duration);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Active Booking id
|
||||
Text(
|
||||
'Active Booking ID: ${widget.orderId}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/views/Dashboard/pickups/route_metrics.dart
Normal file
189
lib/views/Dashboard/pickups/route_metrics.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Live per-stop route metrics — how far away the next stop is, and when the
|
||||
/// rider will get there.
|
||||
///
|
||||
/// Why this is its own file: distance was previously formatted in three
|
||||
/// different places with three different rules, and always in kilometres. A
|
||||
/// stop 80 metres away rendered as `0.1 km`, which is useless when you are
|
||||
/// looking for a gate. Riders think in **metres up close and kilometres far
|
||||
/// away**, so the unit has to switch at the threshold they actually use.
|
||||
///
|
||||
/// Everything here is pure — no Flutter, no I/O — so it is unit-testable and
|
||||
/// safe to call from `build`.
|
||||
class RouteMetricsHelper {
|
||||
RouteMetricsHelper._();
|
||||
|
||||
/// Below this many metres, show metres. At or above it, show kilometres.
|
||||
static const double kMetreThreshold = 1000;
|
||||
|
||||
/// Fallback speed when the rider's live speed is unknown or unusable:
|
||||
/// 20 km/h, a realistic urban two-wheeler average once lights, turns and
|
||||
/// traffic are counted. Riding speed alone would promise ETAs he can't hit.
|
||||
static const double kDefaultSpeedMps = 5.55; // 20 km/h
|
||||
|
||||
/// Speeds below this are noise (stopped at a light, GPS jitter) and must not
|
||||
/// drive an ETA — at 0.5 m/s a 2 km leg would read as over an hour.
|
||||
static const double kMinUsableSpeedMps = 1.5; // ~5.4 km/h
|
||||
|
||||
/// Clamp on live speed. A GPS spike of 200 km/h on a bike is a bad fix, not
|
||||
/// a fast rider, and would promise an ETA that has already passed.
|
||||
static const double kMaxUsableSpeedMps = 22.0; // ~79 km/h
|
||||
|
||||
// ── Distance ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Great-circle distance between two coordinates, in metres.
|
||||
static double distanceMeters(
|
||||
double fromLat,
|
||||
double fromLng,
|
||||
double toLat,
|
||||
double toLng,
|
||||
) {
|
||||
const earthRadiusM = 6371000.0;
|
||||
final dLat = _rad(toLat - fromLat);
|
||||
final dLng = _rad(toLng - fromLng);
|
||||
final h =
|
||||
math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_rad(fromLat)) *
|
||||
math.cos(_rad(toLat)) *
|
||||
math.sin(dLng / 2) *
|
||||
math.sin(dLng / 2);
|
||||
return earthRadiusM * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
|
||||
}
|
||||
|
||||
/// Formats a distance the way a rider reads it.
|
||||
///
|
||||
/// • `< 1000 m` → whole metres, rounded to 10 (`450 m`, `80 m`)
|
||||
/// • `>= 1000 m` → kilometres with one decimal (`3.2 km`, `12.0 km`)
|
||||
/// • `>= 100 km` → whole kilometres (`104 km`) — the decimal is noise
|
||||
///
|
||||
/// Returns `'—'` for unknown/zero, never a misleading `0 m`.
|
||||
static String formatDistance(double? meters) {
|
||||
if (meters == null || meters.isNaN || meters <= 0) return '—';
|
||||
|
||||
if (meters < kMetreThreshold) {
|
||||
// Round to the nearest 10 m — GPS is not accurate to the metre, and
|
||||
// "450 m" is easier to hold in your head than "447 m".
|
||||
final rounded = (meters / 10).round() * 10;
|
||||
return '${rounded < 10 ? 10 : rounded} m';
|
||||
}
|
||||
|
||||
final km = meters / 1000;
|
||||
if (km >= 100) return '${km.round()} km';
|
||||
return '${km.toStringAsFixed(1)} km';
|
||||
}
|
||||
|
||||
/// Distance with the trailing word riders expect on a card: `450 m away`.
|
||||
static String formatDistanceAway(double? meters) {
|
||||
final d = formatDistance(meters);
|
||||
return d == '—' ? 'Distance unknown' : '$d away';
|
||||
}
|
||||
|
||||
// ── Time ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Usable travel speed in m/s, given the rider's live speed.
|
||||
///
|
||||
/// Falls back to [kDefaultSpeedMps] whenever the live reading is missing,
|
||||
/// too slow to be movement, or too fast to be real.
|
||||
static double usableSpeedMps(double? liveSpeedMps) {
|
||||
if (liveSpeedMps == null || liveSpeedMps.isNaN) return kDefaultSpeedMps;
|
||||
if (liveSpeedMps < kMinUsableSpeedMps) return kDefaultSpeedMps;
|
||||
if (liveSpeedMps > kMaxUsableSpeedMps) return kDefaultSpeedMps;
|
||||
return liveSpeedMps;
|
||||
}
|
||||
|
||||
/// Travel time for [meters] at the rider's usable speed.
|
||||
static Duration travelTime(double? meters, {double? liveSpeedMps}) {
|
||||
if (meters == null || meters.isNaN || meters <= 0) return Duration.zero;
|
||||
final seconds = meters / usableSpeedMps(liveSpeedMps);
|
||||
return Duration(seconds: seconds.round());
|
||||
}
|
||||
|
||||
/// Arrival clock time for [meters] away, from [now] (defaults to real now).
|
||||
static DateTime? eta(double? meters, {double? liveSpeedMps, DateTime? now}) {
|
||||
if (meters == null || meters.isNaN || meters <= 0) return null;
|
||||
return (now ?? DateTime.now()).add(
|
||||
travelTime(meters, liveSpeedMps: liveSpeedMps),
|
||||
);
|
||||
}
|
||||
|
||||
/// ETA as a clock time — `4:35 PM`. Returns `'—'` when it can't be computed.
|
||||
static String formatEta(
|
||||
double? meters, {
|
||||
double? liveSpeedMps,
|
||||
DateTime? now,
|
||||
}) {
|
||||
final arrival = eta(meters, liveSpeedMps: liveSpeedMps, now: now);
|
||||
if (arrival == null) return '—';
|
||||
return formatClock(arrival);
|
||||
}
|
||||
|
||||
/// `14:05` → `2:05 PM`.
|
||||
static String formatClock(DateTime t) {
|
||||
final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12;
|
||||
final minute = t.minute.toString().padLeft(2, '0');
|
||||
return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}';
|
||||
}
|
||||
|
||||
/// Duration as a rider reads a clock: `4 min`, `1h 05m`.
|
||||
static String formatDuration(Duration d) {
|
||||
if (d.inSeconds <= 0) return '—';
|
||||
if (d.inMinutes < 1) return '1 min';
|
||||
if (d.inMinutes < 60) return '${d.inMinutes} min';
|
||||
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
return '${d.inHours}h ${minutes}m';
|
||||
}
|
||||
|
||||
/// The one-line strip shown on every stop card:
|
||||
/// `450 m away · ETA 4:35 PM`. Degrades to whichever half is known.
|
||||
static String formatStrip(
|
||||
double? meters, {
|
||||
double? liveSpeedMps,
|
||||
DateTime? now,
|
||||
}) {
|
||||
final distance = formatDistanceAway(meters);
|
||||
final arrival = formatEta(meters, liveSpeedMps: liveSpeedMps, now: now);
|
||||
if (arrival == '—') return distance;
|
||||
return '$distance · ETA $arrival';
|
||||
}
|
||||
|
||||
// ── Stop-map convenience ───────────────────────────────────────────────
|
||||
|
||||
/// Resolves the distance from the rider to a stop, in metres.
|
||||
///
|
||||
/// Prefers a live rider fix; falls back to the coordinates the backend
|
||||
/// stamped on the booking, then to its precomputed `kms` field. Returns null
|
||||
/// when nothing usable exists — callers render `—` rather than a fake zero.
|
||||
static double? metersToStop(
|
||||
Map<String, dynamic> stop, {
|
||||
double? riderLat,
|
||||
double? riderLng,
|
||||
}) {
|
||||
final stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
|
||||
final stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
|
||||
|
||||
final fromLat = (riderLat != null && riderLat != 0)
|
||||
? riderLat
|
||||
: _toDouble(stop['riderslat']);
|
||||
final fromLng = (riderLng != null && riderLng != 0)
|
||||
? riderLng
|
||||
: _toDouble(stop['riderslon']);
|
||||
|
||||
if (stopLat != 0 && stopLng != 0 && fromLat != 0 && fromLng != 0) {
|
||||
return distanceMeters(fromLat, fromLng, stopLat, stopLng);
|
||||
}
|
||||
|
||||
// Backend-precomputed kilometres, if it sent any.
|
||||
final km = _toDouble(stop['kms'] ?? stop['km']);
|
||||
return km > 0 ? km * 1000 : null;
|
||||
}
|
||||
|
||||
static double _toDouble(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toDouble();
|
||||
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
|
||||
return double.tryParse(cleaned) ?? 0;
|
||||
}
|
||||
|
||||
static double _rad(double deg) => deg * math.pi / 180.0;
|
||||
}
|
||||
1261
lib/views/Dashboard/pickups/sheet.dart
Normal file
1261
lib/views/Dashboard/pickups/sheet.dart
Normal file
File diff suppressed because it is too large
Load Diff
405
lib/views/Dashboard/pickups/skip_sheet.dart
Normal file
405
lib/views/Dashboard/pickups/skip_sheet.dart
Normal file
@@ -0,0 +1,405 @@
|
||||
part of 'pickups.dart';
|
||||
|
||||
Future<void> _showMyOptionsSheet(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> pickup,
|
||||
_MyPickupsState? parentState,
|
||||
) async {
|
||||
int selected = -1;
|
||||
bool isLoading = false;
|
||||
|
||||
final List<String> skipReasons = [
|
||||
'Customer unreachable',
|
||||
'Customer not at the location',
|
||||
];
|
||||
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
sheetAnimationStyle: kMilerSheetStyle,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: true,
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
// The reason list is the same question Update Status asks, so it is
|
||||
// the same widget — see [_ReasonRow]. It used to be two outlined
|
||||
// white cards on a white sheet, each with its icon inside a second
|
||||
// box, and the selected one swapped its border for a thicker
|
||||
// coloured one.
|
||||
Widget reasonRow(String title, int index, IconData icon) {
|
||||
return _ReasonRow(
|
||||
label: title,
|
||||
icon: icon,
|
||||
accent: ColorConstants.primary,
|
||||
selected: selected == index,
|
||||
// No rule either side of the filled row: a hairline across the
|
||||
// tint turns the chosen answer back into a box.
|
||||
showRule:
|
||||
index > 0 && selected != index && selected != index - 1,
|
||||
onTap: isLoading
|
||||
? null
|
||||
: () => setState(() => selected = index),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleConfirm() async {
|
||||
if (selected == -1 || isLoading) return;
|
||||
|
||||
final dc = Get.put(PickupsController(), permanent: true);
|
||||
final skipStatus = await dc.checkSkipStatus();
|
||||
final int skipCount = skipStatus['count'] ?? 0;
|
||||
bool applyPenalty = false;
|
||||
|
||||
if (skipCount >= 2) {
|
||||
final bool? confirm = await showAppDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.r),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.errorLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: ColorConstants.errorRed,
|
||||
size: 36.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
Text(
|
||||
'Skip Limit Reached',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.onSurface,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
'You have exceeded the limit of 2 skips within 3 hours.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
Container(
|
||||
padding: EdgeInsets.all(12.r),
|
||||
// Fill, no outline — the same rule the sheets
|
||||
// now follow. A tinted panel is already a panel.
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.warningContainer,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: ColorConstants.warning,
|
||||
size: 18.sp,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Proceeding will forfeit your bonus points for this session.",
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.rewardAmber,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MilerButton(
|
||||
label: "Cancel",
|
||||
onPressed: () =>
|
||||
Navigator.pop(context, false),
|
||||
variant: MilerButtonVariant.outlined,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
height: ButtonSizes.secondary,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: MilerButton(
|
||||
label: "Confirm skip",
|
||||
onPressed: () =>
|
||||
Navigator.pop(context, true),
|
||||
color: ColorConstants.errorRed,
|
||||
height: ButtonSizes.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
applyPenalty = true;
|
||||
}
|
||||
|
||||
setState(() => isLoading = true);
|
||||
|
||||
try {
|
||||
final dc = Get.put(PickupsController(), permanent: true);
|
||||
final d = pickup;
|
||||
final int pickupId = int.tryParse('${d['pickupid'] ?? 0}') ?? 0;
|
||||
final int orderHeaderId =
|
||||
int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0;
|
||||
final String reason =
|
||||
selected >= 0 && selected < skipReasons.length
|
||||
? skipReasons[selected]
|
||||
: '';
|
||||
|
||||
if (pickupId > 0 && orderHeaderId > 0) {
|
||||
String riderLatStr = '0';
|
||||
String riderLngStr = '0';
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
riderLatStr = position.latitude.toStringAsFixed(6);
|
||||
riderLngStr = position.longitude.toStringAsFixed(6);
|
||||
} catch (e) {
|
||||
debugPrint('[SKIP] Error getting rider location: $e');
|
||||
try {
|
||||
final lastPos = await Geolocator.getLastKnownPosition();
|
||||
if (lastPos != null) {
|
||||
riderLatStr = lastPos.latitude.toStringAsFixed(6);
|
||||
riderLngStr = lastPos.longitude.toStringAsFixed(6);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[SKIP] Updating status for pickupId=$pickupId orderHeaderId=$orderHeaderId reason="$reason"',
|
||||
);
|
||||
|
||||
final ok = await dc.updateSkippedStatus(
|
||||
pickupId: pickupId,
|
||||
orderHeaderId: orderHeaderId,
|
||||
ridersLat: riderLatStr,
|
||||
ridersLng: riderLngStr,
|
||||
notes: reason,
|
||||
);
|
||||
|
||||
debugPrint('[SKIP] Status update result: $ok');
|
||||
|
||||
// Optimistic skip — reflect it locally and move on even if the
|
||||
// API fails (e.g. mock/offline data), so skip never appears
|
||||
// unresponsive and the rider can proceed to the next stop.
|
||||
await dc.registerSkip(applyPenalty: applyPenalty);
|
||||
|
||||
// Recorded before the sheet closes, and regardless of which
|
||||
// screen the skip was taken from: the map screen passes no
|
||||
// `parentState`, so the branch below is the only thing that
|
||||
// remembered a skip and it did so in memory, on one screen.
|
||||
// Activity is where the rider now finds a skipped stop, and
|
||||
// it reads this store. See [addSkippedBooking].
|
||||
await addSkippedBooking(d, reason: reason);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
|
||||
if (parentState != null) {
|
||||
parentState.markOrderAsSkipped(d, reason);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[SKIP] Skipped from Nav/Map screen, sheet closed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'[SKIP] Invalid pickupId: $pickupId or orderHeaderId: $orderHeaderId',
|
||||
);
|
||||
if (context.mounted) {
|
||||
AppFeedback.error(context, 'Invalid pickup information');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SKIP] Error updating skip status: $e');
|
||||
if (context.mounted) {
|
||||
AppFeedback.error(context, 'Something went wrong — try again');
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
setState(() => isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frosted, like every other sheet in the app — see
|
||||
// [milerGlassSheet].
|
||||
return milerGlassSheet(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40.w,
|
||||
height: 4.h,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.borderSubtle,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Skip this pickup',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
'Tell us why so we can reassign it',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
InkWell(
|
||||
onTap: isLoading
|
||||
? null
|
||||
: () => Navigator.pop(context),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(8.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
color: ColorConstants.slateText,
|
||||
size: 18.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
// ── Which pickup ──
|
||||
//
|
||||
// The sheet asked the rider to skip "this pickup" and never
|
||||
// said which one. He arrives here from a swipe on a list of
|
||||
// near-identical rows, and skipping the wrong stop is not an
|
||||
// action he can take back — it goes to the office as a
|
||||
// refusal against a customer who was standing there waiting.
|
||||
_StopBrief(pickup: pickup, showRule: true),
|
||||
SizedBox(height: 8.h),
|
||||
reasonRow(
|
||||
'Customer unreachable',
|
||||
0,
|
||||
Icons.phone_missed_rounded,
|
||||
),
|
||||
reasonRow(
|
||||
'Customer not at the location',
|
||||
1,
|
||||
Icons.person_off_outlined,
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
if (isLoading)
|
||||
Container(
|
||||
height: ButtonSizes.primary,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
// Matches the button it stands in for.
|
||||
color: ColorConstants.errorRed,
|
||||
borderRadius: BorderRadius.circular(
|
||||
ButtonSizes.radius,
|
||||
),
|
||||
),
|
||||
child: const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
// ── Skipping is destructive, and looks it ──
|
||||
//
|
||||
// This was `primary` while the penalty confirmation
|
||||
// nested inside the same flow was `errorRed` — one action
|
||||
// in two colours, two taps apart. Skipping tells the hub
|
||||
// a customer was not served; it is the destructive branch
|
||||
// of this screen and takes the destructive colour, the
|
||||
// same one Reject uses on Home.
|
||||
MilerButton(
|
||||
label: 'Confirm skip',
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: ColorConstants.errorRed,
|
||||
onPressed: selected == -1 ? null : handleConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
309
lib/views/Dashboard/pickups/stop_type.dart
Normal file
309
lib/views/Dashboard/pickups/stop_type.dart
Normal file
@@ -0,0 +1,309 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
|
||||
/// Mixed pickup + delivery routes (Amazon/Flipkart-style), including stops
|
||||
/// where the rider must do BOTH at the same address.
|
||||
///
|
||||
/// A route stop is one of three operational modes:
|
||||
///
|
||||
/// • **pickup** — collect parcels from the customer. Photo + weight proof.
|
||||
/// May carry COD (cash / UPI-QR).
|
||||
/// • **delivery** — hand over a parcel carried from the hub. OTP + photo
|
||||
/// proof. Prepaid, so nothing is collected.
|
||||
/// • **combined** — one address, both jobs. e.g. hand over 1 parcel AND
|
||||
/// collect 2 returns. This is a single stop with a two-leg
|
||||
/// flow, NOT two stops — the rider parks once.
|
||||
///
|
||||
/// BACKEND DEPENDENCY: the app does not yet receive a stop `type` field. When
|
||||
/// the backend starts sending `type`/`stoptype` = `pickup` | `delivery` |
|
||||
/// `combined` (or sends both a delivery and a pickup quantity on one stop),
|
||||
/// this helper picks it up automatically and the demo fallback is ignored.
|
||||
enum StopKind { pickup, delivery, combined }
|
||||
|
||||
/// One unit of work inside a stop. A combined stop runs both, in order.
|
||||
enum StopLeg { delivery, pickup }
|
||||
|
||||
/// Demo/mock switch. While the backend has no stop-type field, this makes
|
||||
/// stop 2 a delivery and stop 3 a combined stop so all three card types and
|
||||
/// flows can be exercised on real data.
|
||||
///
|
||||
/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds. Once the
|
||||
/// backend sends `type`, [stopKindOf] prefers it anyway and this is ignored.
|
||||
const bool kDemoMixedRoute = kDebugMode;
|
||||
|
||||
/// Resolve a stop's kind.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. An explicit backend `type` / `stoptype` / `stopType` string.
|
||||
/// 2. Structural inference — a stop carrying BOTH a delivery quantity and a
|
||||
/// pickup quantity is a combined stop even if never labelled one.
|
||||
/// 3. The demo pattern, in debug builds only.
|
||||
/// 4. Pickup — the app's original behaviour.
|
||||
StopKind stopKindOf(Map<String, dynamic> item) {
|
||||
final raw = _rawType(item);
|
||||
|
||||
if (raw == 'combined' ||
|
||||
raw == 'both' ||
|
||||
raw == 'pickup_delivery' ||
|
||||
raw == 'pickupdelivery' ||
|
||||
raw == 'delivery_pickup') {
|
||||
return StopKind.combined;
|
||||
}
|
||||
if (raw == 'delivery' || raw == 'deliver' || raw == 'drop') {
|
||||
return StopKind.delivery;
|
||||
}
|
||||
if (raw == 'pickup' || raw == 'pick' || raw == 'collect') {
|
||||
return StopKind.pickup;
|
||||
}
|
||||
|
||||
// Structural inference — trust the payload when the label is missing.
|
||||
if (_explicitDeliveryQty(item) > 0 && _explicitPickupQty(item) > 0) {
|
||||
return StopKind.combined;
|
||||
}
|
||||
|
||||
if (kDemoMixedRoute) {
|
||||
final stepRaw = item['step'] ?? item['Step'];
|
||||
final step = stepRaw is num
|
||||
? stepRaw.toInt()
|
||||
: int.tryParse('${stepRaw ?? ''}') ?? 0;
|
||||
// Stop 3, 6, 9… → combined; other even stops → delivery. A demo route
|
||||
// therefore shows all three card types.
|
||||
if (step > 0) {
|
||||
if (step % 3 == 0) return StopKind.combined;
|
||||
if (step.isEven) return StopKind.delivery;
|
||||
}
|
||||
}
|
||||
return StopKind.pickup;
|
||||
}
|
||||
|
||||
String _rawType(Map<String, dynamic> item) =>
|
||||
(item['type'] ?? item['stoptype'] ?? item['stopType'] ?? '')
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
int _explicitDeliveryQty(Map<String, dynamic> item) => _toInt(
|
||||
item['deliveryqty'] ??
|
||||
item['deliveryQty'] ??
|
||||
item['dropqty'] ??
|
||||
item['deliverycount'],
|
||||
);
|
||||
|
||||
int _explicitPickupQty(Map<String, dynamic> item) => _toInt(
|
||||
item['pickupqty'] ??
|
||||
item['pickupQty'] ??
|
||||
item['collectqty'] ??
|
||||
item['returnqty'],
|
||||
);
|
||||
|
||||
/// Parcels to be HANDED OVER at this stop (delivery leg). Zero for a
|
||||
/// pickup-only stop.
|
||||
int deliveryParcelCount(Map<String, dynamic> item) {
|
||||
final explicit = _explicitDeliveryQty(item);
|
||||
if (explicit > 0) return explicit;
|
||||
|
||||
// No explicit split: a delivery-labelled stop delivers its whole quantity.
|
||||
return stopKindOf(item) == StopKind.delivery
|
||||
? _toInt(item['Quantity'] ?? item['quantity'])
|
||||
: 0;
|
||||
}
|
||||
|
||||
/// Parcels to be COLLECTED at this stop (pickup leg). Zero for a
|
||||
/// delivery-only stop.
|
||||
int pickupParcelCount(Map<String, dynamic> item) {
|
||||
final explicit = _explicitPickupQty(item);
|
||||
if (explicit > 0) return explicit;
|
||||
|
||||
return stopKindOf(item) == StopKind.delivery
|
||||
? 0
|
||||
: _toInt(item['Quantity'] ?? item['quantity']);
|
||||
}
|
||||
|
||||
/// Cash still to be collected at this stop, in rupees. Deliveries are prepaid,
|
||||
/// so this only ever applies to a pickup leg.
|
||||
double stopCollectionAmount(Map<String, dynamic> item) {
|
||||
final v = item['collectionamt'] ?? item['pickupamt'] ?? item['codamount'];
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toDouble();
|
||||
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
|
||||
return double.tryParse(cleaned) ?? 0;
|
||||
}
|
||||
|
||||
/// The delivery OTP, when the stop has a delivery leg.
|
||||
String stopOtp(Map<String, dynamic> item) =>
|
||||
(item['otp'] ?? item['Otp'] ?? item['deliveryotp'] ?? '').toString().trim();
|
||||
|
||||
int _toInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse(v.toString().trim()) ?? 0;
|
||||
}
|
||||
|
||||
extension StopKindUi on StopKind {
|
||||
/// True only for a delivery-ONLY stop.
|
||||
///
|
||||
/// Deliberately narrow. The confirm/payment path keys off this to mean
|
||||
/// "prepaid, no money, OTP required". A combined stop still has a pickup leg
|
||||
/// that can carry COD, so it must NOT take the delivery-only path — ask
|
||||
/// [hasDelivery] / [hasPickup] about legs instead.
|
||||
bool get isDelivery => this == StopKind.delivery;
|
||||
|
||||
bool get isCombined => this == StopKind.combined;
|
||||
|
||||
/// Does this stop involve handing a parcel over? (delivery + combined)
|
||||
bool get hasDelivery =>
|
||||
this == StopKind.delivery || this == StopKind.combined;
|
||||
|
||||
/// Does this stop involve collecting a parcel? (pickup + combined)
|
||||
bool get hasPickup => this == StopKind.pickup || this == StopKind.combined;
|
||||
|
||||
/// The legs to run, in execution order.
|
||||
///
|
||||
/// Deliver first on a combined stop: the rider walks up with the outbound
|
||||
/// parcel already in his hands, so he hands it over before taking anything
|
||||
/// back. Reversing this makes him juggle two loads at the doorstep.
|
||||
List<StopLeg> get legs => switch (this) {
|
||||
StopKind.pickup => const [StopLeg.pickup],
|
||||
StopKind.delivery => const [StopLeg.delivery],
|
||||
StopKind.combined => const [StopLeg.delivery, StopLeg.pickup],
|
||||
};
|
||||
|
||||
/// Distinct accent per mode so the three are never confused at a glance.
|
||||
Color get accent => switch (this) {
|
||||
StopKind.pickup => ColorConstants.pickupAccent,
|
||||
StopKind.delivery => ColorConstants.deliveryAccent,
|
||||
StopKind.combined => ColorConstants.combinedAccent,
|
||||
};
|
||||
|
||||
/// Fill for the small chip that names this mode, carrying white text.
|
||||
///
|
||||
/// A darkened [accent] — see the note on [ColorConstants.pickupChip]. Use this
|
||||
/// for the tag and nothing else: the chip is the one place stop type is
|
||||
/// allowed to be a colour, because it is a mark rather than a surface.
|
||||
Color get chipColor => switch (this) {
|
||||
StopKind.pickup => ColorConstants.pickupChip,
|
||||
StopKind.delivery => ColorConstants.deliveryChip,
|
||||
StopKind.combined => ColorConstants.combinedChip,
|
||||
};
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
StopKind.pickup => Icons.inventory_2_rounded,
|
||||
StopKind.delivery => Icons.local_shipping_rounded,
|
||||
StopKind.combined => Icons.swap_horiz_rounded,
|
||||
};
|
||||
|
||||
/// Short badge label.
|
||||
String get badgeLabel => switch (this) {
|
||||
StopKind.pickup => 'PICKUP',
|
||||
StopKind.delivery => 'DELIVERY',
|
||||
StopKind.combined => 'PICKUP & DELIVERY',
|
||||
};
|
||||
|
||||
/// Sentence-case name of the mode, for places that state it as a *label*
|
||||
/// rather than stamp it as a chip — see the handoff screen's next-stop block.
|
||||
///
|
||||
/// [badgeLabel] shouts because it is a filled mark competing with a card full
|
||||
/// of other things. When the type is a quiet line under a hero heading there
|
||||
/// is nothing to compete with, and all-caps at that size reads as a second
|
||||
/// heading rather than as an attribute.
|
||||
String get titleLabel => switch (this) {
|
||||
StopKind.pickup => 'Pickup',
|
||||
StopKind.delivery => 'Delivery',
|
||||
StopKind.combined => 'Pickup & Delivery',
|
||||
};
|
||||
|
||||
/// Label for the small chip on a stop card — **both** stop cards.
|
||||
///
|
||||
/// "PICKUP & DELIVERY" is 17 characters. On a stop row it shares a line with a
|
||||
/// duration, a call button and a tick box, all of which have a fixed claim on
|
||||
/// the width — so it was ellipsising down to "P…" and telling the rider
|
||||
/// nothing at all. A chip that has to truncate is not a chip.
|
||||
///
|
||||
/// "P&D" fits at any width and, unlike the "BOTH" it replaces, still names
|
||||
/// the two jobs rather than merely counting them: "BOTH" only means anything
|
||||
/// if you already know what the two things are, which is exactly what a rider
|
||||
/// glancing at an unfamiliar stop does not.
|
||||
///
|
||||
/// The Bookings card used [badgeLabel] here and now uses this, so the two
|
||||
/// stop cards finally agree. The full wording survives where there is room
|
||||
/// for it — the detail sheet and Activity's Type row — which is what
|
||||
/// [badgeLabel] is still for.
|
||||
String get chipLabel => switch (this) {
|
||||
StopKind.pickup => 'PICKUP',
|
||||
StopKind.delivery => 'DELIVERY',
|
||||
StopKind.combined => 'P&D',
|
||||
};
|
||||
|
||||
/// Location-row label.
|
||||
String get locationLabel => switch (this) {
|
||||
StopKind.pickup => 'PICKUP LOCATION',
|
||||
StopKind.delivery => 'DELIVER TO',
|
||||
StopKind.combined => 'DELIVER & COLLECT AT',
|
||||
};
|
||||
|
||||
/// Verb for inline CTAs — "start pickup" / "start delivery" / …
|
||||
String get startVerb => switch (this) {
|
||||
StopKind.pickup => 'start pickup',
|
||||
StopKind.delivery => 'start delivery',
|
||||
StopKind.combined => 'start combined stop',
|
||||
};
|
||||
|
||||
/// Full primary-CTA text on the stop card.
|
||||
String get startCtaLabel => switch (this) {
|
||||
StopKind.pickup => 'Start Pickup',
|
||||
StopKind.delivery => 'Start Delivery',
|
||||
StopKind.combined => 'Start Combined Stop',
|
||||
};
|
||||
|
||||
/// Success verb once the WHOLE stop is finished.
|
||||
String get completeLabel => switch (this) {
|
||||
StopKind.pickup => 'Picked up',
|
||||
StopKind.delivery => 'Delivered',
|
||||
StopKind.combined => 'Complete Stop',
|
||||
};
|
||||
|
||||
/// Failure / abort verb.
|
||||
String get failLabel => switch (this) {
|
||||
StopKind.pickup => 'Cancel',
|
||||
StopKind.delivery => 'Undelivered',
|
||||
StopKind.combined => 'Could not complete',
|
||||
};
|
||||
|
||||
/// Noun used on the success screen.
|
||||
String get noun => switch (this) {
|
||||
StopKind.pickup => 'Pickup',
|
||||
StopKind.delivery => 'Delivery',
|
||||
StopKind.combined => 'Stop',
|
||||
};
|
||||
|
||||
/// Wire value carried in the completion outcome map.
|
||||
String get wireName => switch (this) {
|
||||
StopKind.pickup => 'pickup',
|
||||
StopKind.delivery => 'delivery',
|
||||
StopKind.combined => 'combined',
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse a [StopKind] back from [StopKindUi.wireName]. Unknown → pickup, the
|
||||
/// app's safe default.
|
||||
StopKind stopKindFromWire(dynamic raw) => switch (raw?.toString()) {
|
||||
'delivery' => StopKind.delivery,
|
||||
'combined' => StopKind.combined,
|
||||
_ => StopKind.pickup,
|
||||
};
|
||||
|
||||
extension StopLegUi on StopLeg {
|
||||
bool get isDelivery => this == StopLeg.delivery;
|
||||
|
||||
String get title => isDelivery ? 'Hand over parcel' : 'Collect parcel';
|
||||
|
||||
String get proofHint =>
|
||||
isDelivery ? 'OTP + photo required' : 'Photo + weight required';
|
||||
|
||||
IconData get icon =>
|
||||
isDelivery ? Icons.local_shipping_rounded : Icons.inventory_2_rounded;
|
||||
|
||||
Color get accent =>
|
||||
isDelivery ? ColorConstants.deliveryAccent : ColorConstants.pickupAccent;
|
||||
}
|
||||
2301
lib/views/Dashboard/pickups/stop_verify.dart
Normal file
2301
lib/views/Dashboard/pickups/stop_verify.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user