first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

View 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;
}
}

File diff suppressed because it is too large Load Diff

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

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

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

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

File diff suppressed because it is too large Load Diff

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

View 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;
}

File diff suppressed because it is too large Load Diff

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

View 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;
}

File diff suppressed because it is too large Load Diff