Files
doormile_milderapp/lib/views/Dashboard/pickups/collect_payment.dart
2026-08-28 11:13:15 +05:30

1232 lines
43 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:qr_flutter/qr_flutter.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';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// COLLECT PAYMENT — money taken at the door on the shipper's behalf.
///
/// Miler is the carrier, not the merchant. Nothing collected here is Miler's
/// revenue: it is the shipper's money, held by the rider for the length of a
/// shift and deposited at the hub. Every line on this screen is written from
/// that position, and the custody note at the bottom is the point of the page
/// as much as the slider is.
///
/// The screen this replaces got the central fact wrong. It computed
///
/// _pickupCharge = weight * 15.0 // or 30.0 if weight was blank
///
/// — a freight charge invented on the handset from a number the rider had
/// typed a screen earlier, presented to the customer as the amount owed. The
/// booking's real `collectionamt` was never read. Two riders weighing the same
/// parcel differently would demand different money for the same consignment,
/// and a rider who skipped the optional weight field would demand ₹30 from
/// someone who owed nothing.
///
/// So: the amount comes from the booking, it is not editable, and it is not
/// derived from anything measured at the door. What the rider CAN record is
/// what actually happened — the method, and a short payment with a reason.
/// That is the one thing he genuinely knows and the office does not.
///
/// Card was dropped with the arithmetic. A first-mile miler carries a phone
/// and a bag, not a card terminal, and an option that cannot be honoured is
/// worse than no option at all.
///
/// ── One payment surface, and the QR lives on it ──
///
/// UPI used to be a *dialog*, thrown up after the rider had already chosen a
/// method chip and slid a confirm: a bare QR, an amount, and a "Payment
/// Received" button. Nothing on it said which consignment was being paid for,
/// so the customer — who is being asked to hand over real money at his own
/// doorstep — was shown a code and a number and asked to trust both. If two
/// bookings were being settled at one shop, nothing on that dialog could tell
/// them apart.
///
/// The QR now renders inline, on the page that already carries the order, the
/// parcels and the amount, one tap after choosing UPI. That is a tap *fewer*
/// than the dialog it replaces (choose → slide → confirm becomes choose →
/// slide) and the customer can see what he is paying for while he scans, which
/// is what every payment surface worth trusting — Uber's fare breakdown, a
/// Rapido receipt, any UPI merchant screen — puts next to the amount.
/// ─────────────────────────────────────────────────────────────────────────
class CollectPaymentPage extends StatefulWidget {
final Map<String, dynamic> pickup;
/// What the verification step recorded. Read-only here — this page never
/// derives money from it, it only shows the consignment it belongs to.
final Map<String, dynamic> verificationData;
/// Method to open on, when the caller already asked. The Bookings confirm
/// sheet does: the rider picks "Cash" or "QR" there, and re-asking him the
/// same question on arrival would be the extra tap this page exists to
/// remove. Null opens on cash, which is what most doors pay in.
final String? initialMethod;
const CollectPaymentPage({
super.key,
required this.pickup,
this.verificationData = const {},
this.initialMethod,
});
@override
State<CollectPaymentPage> createState() => _CollectPaymentPageState();
}
/// The UPI intent a payment QR encodes.
///
/// Top-level and pure so it can be tested directly: the QR widget keeps its
/// payload private, and this string is the one part of the payment flow where a
/// silent mistake — a wrong amount, an unescaped shop name that truncates the
/// payee — takes money from a real person and cannot be undone from the app.
///
/// The amount is baked in so the customer cannot mistype it, and the
/// consignment rides in the note so the payment is identifiable in his own bank
/// statement weeks later, which is what makes a dispute resolvable at all.
String buildUpiUri({
required String payeeVpa,
required String payeeName,
required String orderId,
required double amount,
}) {
final note = orderId.trim().isEmpty
? 'Miler pickup'
: 'Miler pickup $orderId';
return 'upi://pay'
'?pa=${Uri.encodeComponent(payeeVpa)}'
'&pn=${Uri.encodeComponent(payeeName)}'
'&tn=${Uri.encodeComponent(note)}'
'&am=${amount.toStringAsFixed(2)}'
'&cu=INR';
}
enum _Method { cash, upi }
extension _MethodUi on _Method {
String get label => this == _Method.cash ? 'Cash' : 'UPI / QR';
String get blurb => this == _Method.cash
? 'Notes and coins, into your bag'
: 'Customer scans and pays';
IconData get icon =>
this == _Method.cash ? LucideIcons.banknote : LucideIcons.qrCode;
Color get accent => this == _Method.cash
? ColorConstants.acceptGreen
: ColorConstants.upiPurple;
String get wireName => name;
}
class _CollectPaymentPageState extends State<CollectPaymentPage> {
late final double _due;
/// The separate freight charge, when the booking carries one distinct from
/// the collection amount. Shown as a line, never added to anything — see
/// [_breakdown].
late final double _pickupCharge;
late final String _customerName;
late final String _shipper;
late final String _orderId;
late final String _address;
late final StopKind _kind;
late final int _parcels;
late final String _weight;
late final String _itemNote;
/// Where a UPI payment goes. Read from the booking when the backend names a
/// payee, so a shipper collecting into its own VPA is honoured; otherwise the
/// hub's, because the rider is the one who reconciles at the end of the day.
late final String _payeeVpa;
_Method _method = _Method.cash;
/// The customer paid less than the booking says. Off by default: a short
/// payment is the exception, and defaulting to it invites the rider to close
/// every stop as short.
bool _short = false;
final TextEditingController _received = TextEditingController();
final TextEditingController _shortReason = TextEditingController();
/// UPI reference (UTR). Optional — the rider often cannot see it, and
/// blocking on a number printed on the customer's screen would strand him.
final TextEditingController _reference = TextEditingController();
bool _submitting = false;
/// Hub VPA of last resort. Replace with the real one before release — a QR
/// that resolves to nothing is worse than no QR, because the customer
/// believes he has paid.
static const String _fallbackVpa = 'miler@upi';
@override
void initState() {
super.initState();
final d = widget.pickup;
final v = widget.verificationData;
_due = stopCollectionAmount(d);
_pickupCharge = _amountOf(d['pickupamt']);
_customerName = (d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer')
.toString();
_shipper = (d['tenantname'] ?? d['TenantName'] ?? '').toString().trim();
_orderId = (d['orderid'] ?? '').toString();
_address = (d['pickupaddress'] ?? d['PickupAddress'] ?? '')
.toString()
.trim();
_kind = stopKindOf(d);
// What the rider actually took, when verification counted it — that is a
// truer description of what is being paid for than the manifest quantity.
final counted = (v['pickup'] is Map) ? v['pickup']['collected'] : null;
_parcels = counted is int
? counted
: (int.tryParse('${counted ?? ''}') ?? pickupParcelCount(d));
_weight = (v['weight'] ?? '').toString().trim();
_itemNote = (v['description'] ?? '').toString().trim();
_payeeVpa =
_firstNonEmpty(d, const [
'upiid',
'upiId',
'upi_id',
'payeevpa',
'payeeVpa',
'vpa',
'tenantupi',
]) ??
_fallbackVpa;
if ((widget.initialMethod ?? '').toLowerCase().contains('qr') ||
(widget.initialMethod ?? '').toLowerCase().contains('upi')) {
_method = _Method.upi;
}
}
static double _amountOf(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 String? _firstNonEmpty(Map<String, dynamic> m, List<String> keys) {
for (final k in keys) {
final v = (m[k] ?? '').toString().trim();
if (v.isNotEmpty) return v;
}
return null;
}
@override
void dispose() {
_received.dispose();
_shortReason.dispose();
_reference.dispose();
super.dispose();
}
double get _collected {
if (!_short) return _due;
return double.tryParse(_received.text.trim()) ?? 0;
}
String? get _blockingHint {
if (!_short) return null;
final entered = double.tryParse(_received.text.trim());
if (entered == null) return 'Enter how much you actually took';
if (entered < 0) return 'That is not an amount';
if (entered >= _due) {
return 'A short payment is less than ₹${_fmt(_due)}';
}
if (_shortReason.text.trim().length < 3) {
return 'Say why the customer paid less';
}
return null;
}
String get _upiUri => buildUpiUri(
payeeVpa: _payeeVpa,
payeeName: _shipper.isEmpty ? 'Miler Logistics' : _shipper,
orderId: _orderId,
amount: _due,
);
void _finish({required bool paid}) {
if (_submitting) return;
if (paid && _blockingHint != null) {
_toast(_blockingHint!);
return;
}
setState(() => _submitting = true);
Navigator.pop(context, <String, dynamic>{
'paid': paid,
// Kept for the old call sites, which only ever checked truthiness.
'verified': true,
'method': paid ? _method.wireName : null,
'amountDue': _due,
'amountCollected': paid ? _collected : 0.0,
'shortBy': paid ? (_due - _collected) : _due,
'shortReason': _short ? _shortReason.text.trim() : '',
'reference': (paid && _method == _Method.upi)
? _reference.text.trim()
: '',
'orderId': _orderId,
});
}
void _declinePayment() async {
// Through the kit. The route flags here were already the right ones by
// hand — transparent, scroll-controlled, the app's entrance — which is
// exactly the problem a presenter solves: four correct copies are four
// places for the fifth to be written wrong.
final ok = await showMilerSheet<bool>(
context,
builder: (_) => _ConfirmSheet(
title: 'Customer is not paying?',
body:
'The stop stays open and the office is told nothing was '
'collected. Only do this if the customer has actually refused or '
'cannot pay right now.',
confirmLabel: 'Nothing collected',
),
);
if (ok == true && mounted) _finish(paid: false);
}
/// Every message on this screen goes through the one feedback shape.
/// See [AppFeedback].
void _toast(String message) => AppFeedback.error(context, message);
static String _fmt(double v) =>
v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(2);
@override
Widget build(BuildContext context) {
// Defensive: the caller should not open this page for a stop with nothing
// to collect, but a booking can lose its amount between list and door.
if (_due <= 0) return _nothingToCollect();
return Scaffold(
// Grey page, white bands — the same shape the verification screens this
// follows now take, so the money step reads as the next page of one flow
// rather than a different screen. See [MilerBand].
backgroundColor: ColorConstants.daylightSurface,
// Same bar as the verification screens this follows: taller, clear of
// the notch, title at a size that survives sunlight and arm's length.
appBar: milerPageBar(
'Collect payment',
onBack: () => Navigator.pop(context),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
// The bands carry the gutter. See [MilerBand].
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_amountCard(),
SizedBox(height: MilerBand.gap),
_orderCard(),
SizedBox(height: MilerBand.gap),
_methodCard(),
SizedBox(height: MilerBand.gap),
_shortCard(),
SizedBox(height: MilerBand.gap),
_custodyNote(),
],
),
),
),
_bottomBar(),
],
),
);
}
// ── The amount ─────────────────────────────────────────────────────────
//
// Read from the booking and shown as a fact, not a field. The rider is a
// courier, not a cashier: he does not get to price the consignment at the
// door, and a number he can edit is a number he will be argued into
// changing.
Widget _amountCard() {
return MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 20.h, MilerBand.pad, 18.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'DUE AT THIS STOP',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'₹${_fmt(_due)}',
style: TextStyle(
fontSize: 42.sp,
fontWeight: FontWeight.w700,
letterSpacing: -1.6,
height: 1.05,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(height: 4.h),
Text(
'from $_customerName',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
..._breakdown(),
],
),
);
}
/// What makes up the figure above, when the booking actually says.
///
/// ── Why this is a list of what the booking carries, and not a sum ──
///
/// It is tempting to add the collection amount to the pickup charge and print
/// a total. That is exactly the mistake the screen before this one made, from
/// the other end: the app deciding what the customer owes. The office sets
/// the figure, `stopCollectionAmount` reads it, and everything here is
/// *explanation* of a number that has already been decided elsewhere. So the
/// components are listed where they are known, and the due line repeats the
/// authoritative figure rather than deriving it — if the two ever disagree,
/// the rider takes what the booking says, and the discrepancy is the office's
/// to reconcile, not his to arbitrate at a doorstep.
List<Widget> _breakdown() {
final lines = <(String, double)>[
if (_pickupCharge > 0 && _pickupCharge != _due)
('Pickup charge', _pickupCharge),
];
if (lines.isEmpty) return const [];
return [
SizedBox(height: 12.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 10.h),
for (final line in lines) ...[
_breakdownRow(line.$1, '₹${_fmt(line.$2)}'),
SizedBox(height: 7.h),
],
_breakdownRow('Due now', '₹${_fmt(_due)}', strong: true),
];
}
Widget _breakdownRow(String label, String value, {bool strong = false}) {
return Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: strong ? FontWeight.w700 : FontWeight.w500,
color: strong
? ColorConstants.slateText
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
Text(
value,
style: TextStyle(
fontSize: strong ? 14.sp : 12.5.sp,
fontWeight: FontWeight.w700,
color: strong
? ColorConstants.slateText
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
// ── What the money is for ──────────────────────────────────────────────
//
// ── Why a payment screen carries the consignment ──
//
// The customer is being asked, at his own door, to hand over money or scan a
// stranger's QR. "₹450" alone does not earn that: it is a number on a phone
// held by someone he met a minute ago. What makes a payment surface
// trustworthy is that it names the thing being paid for, and every payment
// screen the rider's customers already use — Uber's fare breakdown, a Rapido
// receipt, a merchant's UPI screen — does exactly this.
//
// It also settles the rider's own most common argument. At a shop with two
// bookings open, "which one is this for?" was unanswerable from the old
// dialog; here it is the second line on the page.
Widget _orderCard() {
final facts = <(IconData, String, String)>[
if (_orderId.isNotEmpty)
(LucideIcons.receiptText, 'Consignment', _orderId),
if (_shipper.isNotEmpty) (LucideIcons.store, 'On behalf of', _shipper),
if (_parcels > 0)
(
_kind.icon,
_parcels == 1 ? 'Parcel' : 'Parcels',
'$_parcels collected',
),
if (_weight.isNotEmpty) (LucideIcons.weight, 'Weight', '$_weight kg'),
if (_itemNote.isNotEmpty) (LucideIcons.stickyNote, 'Noted', _itemNote),
];
return _card(
title: 'What this pays for',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < facts.length; i++) ...[
if (i > 0) SizedBox(height: 9.h),
_metaRow(facts[i].$1, facts[i].$2, facts[i].$3),
],
if (_address.isNotEmpty) ...[
SizedBox(height: 11.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 10.h),
Text(
_address,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
height: 1.35,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
],
),
);
}
Widget _metaRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 14.sp, color: ColorConstants.secondaryText),
SizedBox(width: 7.w),
// Flexible: at a large system font "On behalf of" plus a shipper name
// is wider than the card, and the label is the half that can afford to
// give — the value is the fact.
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
);
}
// ── How it was paid ────────────────────────────────────────────────────
Widget _methodCard() {
return _card(
title: 'How did they pay?',
child: Column(
children: [
for (final m in _Method.values) ...[
if (m != _Method.values.first) SizedBox(height: 9.h),
_methodTile(m),
],
if (_method == _Method.upi) ...[
SizedBox(height: 14.h),
_qrPanel(),
SizedBox(height: 12.h),
_field(
controller: _reference,
hint: 'UPI reference (optional)',
icon: LucideIcons.tag,
keyboard: TextInputType.number,
formatters: [FilteringTextInputFormatter.digitsOnly],
),
],
],
),
);
}
/// The QR, on the page, with the amount over it and the payee under it.
///
/// Turned toward the customer — so everything he needs to check before he
/// scans is inside one glance of the code itself: what he is paying, and to
/// whom. The amount is baked into the intent, so he cannot pay the wrong
/// figure by mistyping it.
Widget _qrPanel() {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 14.w),
decoration: BoxDecoration(
color: ColorConstants.upiPurple.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Column(
children: [
Text(
'Show this to the customer',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.upiPurple,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 12.h),
Container(
padding: EdgeInsets.all(12.r),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: DesignConstants.shadowSm,
),
child: QrImageView(
data: _upiUri,
version: QrVersions.auto,
// Fixed, and deliberately large: this is read by another phone's
// camera at arm's length, often in sunlight. Scaling it with the
// screen would make it marginal on the small handsets riders
// actually carry.
size: 190,
gapless: false,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: ColorConstants.slateText,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: ColorConstants.slateText,
),
),
),
SizedBox(height: 12.h),
Text(
'₹${_fmt(_due)} to $_payeeVpa',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 3.h),
Text(
'Any UPI app · the amount is already filled in',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
Widget _methodTile(_Method m) {
final selected = _method == m;
return PressScale(
onTap: () => setState(() => _method = m),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
decoration: BoxDecoration(
color: selected
? m.accent.withValues(alpha: 0.14)
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Row(
children: [
Container(
padding: EdgeInsets.all(8.r),
decoration: BoxDecoration(
color: m.accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(m.icon, size: 18.sp, color: m.accent),
),
SizedBox(width: 11.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
m.label,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
Text(
m.blurb,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
Icon(
selected ? LucideIcons.circleDot : LucideIcons.circle,
size: 20.sp,
color: selected ? m.accent : ColorConstants.borderStrong,
),
],
),
),
);
}
// ── Short payment ──────────────────────────────────────────────────────
//
// The rider cannot change what is owed, but he must be able to report what
// he actually took. Without this he has two bad options at a door where the
// customer is ₹50 short: claim the full amount and carry the gap himself, or
// abandon a stop he has already worked.
Widget _shortCard() {
return _card(
title: 'Did they pay the full amount?',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _toggle(
label: 'Paid ₹${_fmt(_due)}',
selected: !_short,
accent: ColorConstants.acceptGreen,
onTap: () => setState(() => _short = false),
),
),
SizedBox(width: 9.w),
Expanded(
child: _toggle(
label: 'Paid less',
selected: _short,
accent: ColorConstants.warning,
onTap: () => setState(() => _short = true),
),
),
],
),
if (_short) ...[
SizedBox(height: 12.h),
_field(
controller: _received,
hint: 'Amount you actually took',
icon: LucideIcons.indianRupee,
keyboard: const TextInputType.numberWithOptions(decimal: true),
formatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
],
),
SizedBox(height: 10.h),
_field(
controller: _shortReason,
hint: 'Why? e.g. customer had no change',
icon: LucideIcons.stickyNote,
maxLines: 2,
capitalization: TextCapitalization.sentences,
),
if (_collected > 0 && _collected < _due) ...[
SizedBox(height: 11.h),
_notice(
'₹${_fmt(_due - _collected)} still owed on this consignment',
ColorConstants.warning,
),
],
],
],
),
);
}
// ── Custody ────────────────────────────────────────────────────────────
//
// The whole reason this screen reads differently from a shop's checkout.
// The rider is holding somebody else's money, and the app should say so
// once, plainly, at the moment he takes it.
Widget _custodyNote() {
final onBehalf = _shipper.isNotEmpty ? _shipper : 'the shipper';
return MilerBand(
color: ColorConstants.tint(ColorConstants.tertiary, 0.08),
rail: ColorConstants.tertiary,
padding: EdgeInsets.fromLTRB(MilerBand.pad, 13.h, MilerBand.pad, 13.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(LucideIcons.wallet, size: 17.sp, color: ColorConstants.tertiary),
SizedBox(width: 10.w),
Expanded(
child: Text(
'This money belongs to $onBehalf, not to you and not to Miler. '
'Hand it in at the end of your shift.',
style: TextStyle(
fontSize: 12.sp,
height: 1.4,
fontWeight: FontWeight.w600,
color: ColorConstants.tertiary,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
// ── Bottom bar ─────────────────────────────────────────────────────────
Widget _bottomBar() {
final hint = _blockingHint;
return Container(
padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 12.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
border: Border(
top: BorderSide(color: ColorConstants.borderSubtle, width: 1),
),
boxShadow: DesignConstants.shadowSm,
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hint != null) ...[
Text(
hint,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 9.h),
],
_slider(enabled: hint == null),
SizedBox(height: 8.h),
TextButton(
onPressed: _submitting ? null : _declinePayment,
child: Text(
'Customer cannot pay',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.errorRed,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
}
Widget _slider({required bool enabled}) {
if (_submitting) {
return Container(
height: ButtonSizes.primary,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen,
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
),
);
}
if (!enabled) {
return Container(
height: ButtonSizes.primary,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
child: Text(
'Finish the details above',
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 - 28.w,
backgroundColor: _method.accent,
dismissThresholds: 0.85,
action: () async {
_finish(paid: true);
return true;
},
label: Text(
'Slide — took ₹${_fmt(_collected)} by ${_method.label}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.5.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(LucideIcons.check, size: 22.sp, color: _method.accent),
),
),
);
}
// ── Nothing owed ───────────────────────────────────────────────────────
Widget _nothingToCollect() {
return Scaffold(
backgroundColor: ColorConstants.pureSurface,
appBar: AppBar(
backgroundColor: ColorConstants.pureSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Text(
'Collect payment',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
body: Center(
child: Padding(
padding: EdgeInsets.all(28.r),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.circleCheck,
size: 54.sp,
color: ColorConstants.acceptGreen,
),
SizedBox(height: 14.h),
Text(
'Nothing to collect here',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
'This consignment is prepaid. Carry on to the next stop.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.sp,
height: 1.4,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 20.h),
MilerButton(
label: 'Continue',
icon: LucideIcons.arrowRight,
color: ColorConstants.acceptGreen,
onPressed: () => Navigator.pop(context, <String, dynamic>{
'paid': true,
'verified': true,
'amountDue': 0.0,
'amountCollected': 0.0,
}),
),
],
),
),
),
);
}
// ── Shared pieces ──────────────────────────────────────────────────────
Widget _card({required String title, required Widget child}) {
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 12.h),
child,
],
),
);
}
Widget _toggle({
required String label,
required bool selected,
required Color accent,
required VoidCallback onTap,
}) {
return PressScale(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 8.w),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? accent : ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
);
}
Widget _field({
required TextEditingController controller,
required String hint,
required IconData icon,
int maxLines = 1,
TextInputType? keyboard,
List<TextInputFormatter>? formatters,
TextCapitalization capitalization = TextCapitalization.none,
}) {
return TextField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboard,
inputFormatters: formatters,
textCapitalization: capitalization,
onChanged: (_) => setState(() {}),
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
prefixIcon: Icon(
icon,
size: 18.sp,
color: ColorConstants.secondaryText,
),
filled: true,
fillColor: ColorConstants.pureSurface,
contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 13.h),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.primary, width: 1.6),
),
),
);
}
Widget _notice(String text, Color accent) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
children: [
Icon(LucideIcons.info, size: 16.sp, color: accent),
SizedBox(width: 8.w),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
height: 1.35,
fontWeight: FontWeight.w600,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
}
/// Small confirmation sheet for the one destructive choice on this page.
class _ConfirmSheet extends StatelessWidget {
final String title;
final String body;
final String confirmLabel;
const _ConfirmSheet({
required this.title,
required this.body,
required this.confirmLabel,
});
@override
Widget build(BuildContext context) {
// The kit's fabric — this was a floating white card with margins on all
// sides, the one sheet in the app not anchored to the bottom edge.
return MilerSheetScaffold(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MilerSheetHeader(title: title, subtitle: body),
SizedBox(height: 18.h),
Row(
children: [
Expanded(
child: MilerButton(
label: 'Go back',
variant: MilerButtonVariant.outlined,
color: ColorConstants.slateText,
height: ButtonSizes.secondary,
onPressed: () => Navigator.pop(context, false),
),
),
SizedBox(width: 10.w),
Expanded(
child: MilerButton(
label: confirmLabel,
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
onPressed: () => Navigator.pop(context, true),
),
),
],
),
],
),
);
}
}