import 'package:flutter/material.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/page_transitions.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 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 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 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 ? Icons.payments_rounded : Icons.qr_code_2_rounded; Color get accent => this == _Method.cash ? ColorConstants.acceptGreen : ColorConstants.upiPurple; String get wireName => name; } class _CollectPaymentPageState extends State { 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 m, List 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, { '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 { final ok = await showModalBottomSheet( context: context, sheetAnimationStyle: kMilerSheetStyle, backgroundColor: Colors.transparent, isScrollControlled: true, 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.w800, 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.w800, 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.w700, 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 _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.w800 : FontWeight.w600, color: strong ? ColorConstants.slateText : ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), Text( value, style: TextStyle( fontSize: strong ? 14.sp : 12.5.sp, fontWeight: FontWeight.w800, 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) (Icons.receipt_long_rounded, 'Consignment', _orderId), if (_shipper.isNotEmpty) (Icons.storefront_rounded, 'On behalf of', _shipper), if (_parcels > 0) ( _kind.icon, _parcels == 1 ? 'Parcel' : 'Parcels', '$_parcels collected', ), if (_weight.isNotEmpty) (Icons.scale_rounded, 'Weight', '$_weight kg'), if (_itemNote.isNotEmpty) (Icons.notes_rounded, '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.w600, 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.w600, 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.w800, 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: Icons.tag_rounded, 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.w800, 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.w800, 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.w600, 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.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), Text( m.blurb, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), Icon( selected ? Icons.radio_button_checked_rounded : Icons.radio_button_unchecked_rounded, 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: Icons.currency_rupee_rounded, 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: Icons.notes_rounded, 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( Icons.account_balance_wallet_rounded, 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.w700, 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.w700, 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.w700, 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.w700, 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.w800, 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: _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.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), body: Center( child: Padding( padding: EdgeInsets.all(28.r), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.check_circle_rounded, size: 54.sp, color: ColorConstants.acceptGreen, ), SizedBox(height: 14.h), Text( 'Nothing to collect here', style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w800, 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.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 20.h), MilerButton( label: 'Continue', icon: Icons.arrow_forward_rounded, color: ColorConstants.acceptGreen, onPressed: () => Navigator.pop(context, { '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.w800, 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.w800, 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? 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.w700, 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(Icons.info_rounded, size: 16.sp, color: accent), SizedBox(width: 8.w), Expanded( child: Text( text, style: TextStyle( fontSize: 12.sp, height: 1.35, fontWeight: FontWeight.w700, 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) { return SafeArea( top: false, child: Container( margin: EdgeInsets.all(14.r), padding: EdgeInsets.all(18.r), decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w800, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 7.h), Text( body, style: TextStyle( fontSize: 13.sp, height: 1.45, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), 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), ), ), ], ), ], ), ), ); } }