Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1244 lines
43 KiB
Dart
1244 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(
|
|
// White page, tinted panels — the same inversion the verification screens
|
|
// it follows now use. See the note there.
|
|
backgroundColor: ColorConstants.pureSurface,
|
|
// 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(),
|
|
padding: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 24.h),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_amountCard(),
|
|
SizedBox(height: 14.h),
|
|
_orderCard(),
|
|
SizedBox(height: 14.h),
|
|
_methodCard(),
|
|
SizedBox(height: 14.h),
|
|
_shortCard(),
|
|
SizedBox(height: 14.h),
|
|
_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 Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.fromLTRB(18.w, 20.h, 18.w, 18.h),
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.cardSurface,
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
),
|
|
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 Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(13.r),
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.tertiary.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
),
|
|
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. '
|
|
'Deposit it at the hub 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 Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(16.r),
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.cardSurface,
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|