import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.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'; /// ───────────────────────────────────────────────────────────────────────── /// REVIEW & PAY — the last screen before a shipment exists. /// /// ``` /// verify parcel ──▶ shipment desk ──▶ [ REVIEW & PAY ] ──▶ order created /// everything on /// one screen /// ``` /// /// ── Why this replaced a review, a payment page and a confirm sheet ── /// /// The rider used to close a logistics stop across four screens: the verify /// page's own review, then the shipment desk, then a confirm sheet, then a /// payment page. Three of those asked him to look at the same facts again, and /// the one that took the money was the furthest from the one that priced it — /// so the number he quoted the customer and the number he collected were two /// screens apart with no way to see them together. /// /// This is the single review: what is being shipped, where from, where to, what /// it weighs, what it costs, and how the customer is paying — then one button /// that says the amount out loud. **`Pay ₹150`**, not `Confirm`. A rider taking /// cash on a doorstep should never have to remember what the total was. /// /// ── The button is the commit ── /// /// Pressing it does three things in the order the contract requires: records /// the payment, then posts `pickup-complete`, which converts the booking into a /// live consignment with a tracking number. Only then does the screen change. /// If any of it fails the rider stays here with the reason — nothing is /// reported as created that was not created. /// ───────────────────────────────────────────────────────────────────────── /// How the customer paid. The wire values the payment endpoint expects. enum ShipmentPayMethod { cash, upi } extension ShipmentPayMethodX on ShipmentPayMethod { String get label => switch (this) { ShipmentPayMethod.cash => 'Cash', ShipmentPayMethod.upi => 'UPI', }; IconData get icon => switch (this) { ShipmentPayMethod.cash => LucideIcons.banknote, ShipmentPayMethod.upi => LucideIcons.qrCode, }; /// Lower-case, matching what `UpdatePickupProvider.submitPayment` maps from. String get wireName => name; } class ShipmentReviewPage extends StatefulWidget { /// The stop being closed. final Map pickup; /// What the shipment desk captured — from, to, weight, zone, price. final Map shipment; /// Takes the money and creates the order. Returns null on success, or a /// message to show the rider when it could not be completed. /// /// Injected rather than called directly so this page stays a widget: the /// caller owns the provider, the geofence and the controller. final Future Function(ShipmentPayMethod method, double amount) onConfirm; /// Read once the order exists, for the receipt line. final String Function() trackingNo; const ShipmentReviewPage({ super.key, required this.pickup, required this.shipment, required this.onConfirm, required this.trackingNo, }); @override State createState() => _ShipmentReviewPageState(); } class _ShipmentReviewPageState extends State with TickerProviderStateMixin { ShipmentPayMethod _method = ShipmentPayMethod.cash; bool _busy = false; bool _created = false; String? _error; // ── Built in initState, deliberately, not as `late final` ── // // A `late final` controller is only constructed on first read — and on a // review the rider abandons, the first read is `dispose()`. Creating a Ticker // there looks up `TickerMode` on an element that is already deactivated, // which throws while the tree is being torn down. Eager construction is the // only ordering that is correct for every exit from this screen. late final AnimationController _successController; late final Animation _tickScale; late final Animation _ringScale; late final Animation _copyFade; @override void initState() { super.initState(); _successController = AnimationController( vsync: this, duration: DesignConstants.motionCelebrate, ); _tickScale = CurvedAnimation( parent: _successController, // Overshoots and settles — the shape of a stamp coming down, which is // what this moment is. A linear tick reads as a loading state that // stopped. curve: Curves.elasticOut, ); _ringScale = CurvedAnimation( parent: _successController, curve: const Interval(0, 0.55, curve: Curves.easeOutCubic), ); _copyFade = CurvedAnimation( parent: _successController, curve: const Interval(0.35, 1, curve: Curves.easeOut), ); } @override void dispose() { _successController.dispose(); super.dispose(); } double get _amount { final v = widget.shipment['price']; if (v is num) return v.toDouble(); return double.tryParse('${v ?? ''}') ?? 0; } String get _currency => (widget.shipment['currency'] ?? 'INR').toString(); /// `₹150` — the figure on the button and in the summary, from one place so /// the two can never disagree. String get _amountLabel { final symbol = _currency == 'INR' ? '₹' : '$_currency '; return '$symbol${_amount.toStringAsFixed(0)}'; } String _s(dynamic v) => (v ?? '').toString().trim(); Map get _from => (widget.shipment['from'] as Map?)?.cast() ?? {}; Map get _to => (widget.shipment['to'] as Map?)?.cast() ?? {}; Future _confirm() async { if (_busy) return; setState(() { _busy = true; _error = null; }); final failure = await widget.onConfirm(_method, _amount); if (!mounted) return; if (failure != null) { // Stayed here on purpose. The order does not exist, and a success // animation over a failed create is the one thing this screen must never // do — the rider would ride off believing a shipment had been raised. setState(() { _busy = false; _error = failure; }); return; } setState(() { _busy = false; _created = true; }); _successController.forward(); } @override Widget build(BuildContext context) { return PopScope( // Once the order exists, going back would offer to create it twice. canPop: !_created && !_busy, child: Scaffold( backgroundColor: ColorConstants.daylightSurface, appBar: _created ? null : milerPageBar('Review & pay'), body: SafeArea(child: _created ? _successView() : _reviewView()), ), ); } // ── Review ───────────────────────────────────────────────────────────── Widget _reviewView() { return Column( children: [ Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _routeCard(), SizedBox(height: 14.h), const SectionHeader(title: 'Shipment'), SizedBox(height: 8.h), _shipmentCard(), SizedBox(height: 14.h), const SectionHeader(title: 'Payment'), SizedBox(height: 8.h), _paymentCard(), if (_error != null) ...[ SizedBox(height: 14.h), InfoBanner( icon: LucideIcons.circleAlert, text: _error!, color: ColorConstants.errorRed, ), ], ], ), ), ), _payBar(), ], ); } /// From → to, drawn as a journey rather than two labelled fields. Widget _routeCard() { return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _routeEnd( icon: LucideIcons.circleDot, color: ColorConstants.primary, label: 'FROM', address: _s(_from['address']), pincode: _s(_from['pincode']), ), // The connector. Reads as one journey, which two stacked address // blocks do not. Padding( padding: EdgeInsets.only(left: 9.w), child: SizedBox( height: 22.h, child: VerticalDivider( width: 2.w, thickness: 2, color: ColorConstants.borderSubtle, ), ), ), _routeEnd( icon: LucideIcons.mapPin, color: ColorConstants.acceptGreen, label: 'TO', address: _s(_to['address']), pincode: _s(_to['pincode']), city: _s(_to['city']), ), ], ), ); } Widget _routeEnd({ required IconData icon, required Color color, required String label, required String address, required String pincode, String city = '', }) { final tail = [ if (city.isNotEmpty) city, if (pincode.isNotEmpty) pincode, ].join(' · '); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 18.sp, color: color), SizedBox(width: 10.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( fontSize: 10.5.sp, fontWeight: FontWeight.w700, letterSpacing: 0.6, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 2.h), Text( address.isEmpty ? 'Not captured' : address, style: TextStyle( fontSize: 13.5.sp, fontWeight: FontWeight.w600, height: 1.3, color: address.isEmpty ? ColorConstants.secondaryText : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), if (tail.isNotEmpty) Padding( padding: EdgeInsets.only(top: 2.h), child: Text( tail, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ], ); } Widget _shipmentCard() { final weight = widget.shipment['weight']; final km = widget.shipment['approxkm']; return AppCard( child: Column( children: [ _row('Weight', weight == null ? '—' : '$weight kg'), _divider(), _row('Category', _s(widget.shipment['category'])), _divider(), _row('Service', _s(widget.shipment['servicetype'])), if (_s(widget.shipment['zone']).isNotEmpty) ...[ _divider(), _row('Zone', _s(widget.shipment['zone'])), ], if (km is num) ...[ _divider(), _row( 'Distance', '≈ ${km.toStringAsFixed(1)} km', // Said plainly: it is a straight line between two pins, and the // fare comes from the zone. Letting the rider assume otherwise is // how a customer gets told the wrong basis for a charge. note: 'straight line — the fare uses the zone', ), ], ], ), ); } Widget _paymentCard() { return AppCard( child: Column( children: [ Row( children: [ for (final m in ShipmentPayMethod.values) ...[ Expanded(child: _methodTile(m)), if (m != ShipmentPayMethod.values.last) SizedBox(width: 8.w), ], ], ), SizedBox(height: 14.h), Row( children: [ Expanded( child: Text( 'Amount to collect', style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), Text( _amountLabel, style: TextStyle( fontSize: 20.sp, fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), ], ), ], ), ); } Widget _methodTile(ShipmentPayMethod m) { final selected = _method == m; return GestureDetector( onTap: _busy ? null : () => setState(() => _method = m), child: AnimatedContainer( duration: DesignConstants.motionState, padding: EdgeInsets.symmetric(vertical: 12.h), decoration: BoxDecoration( color: selected ? ColorConstants.primary.withValues(alpha: 0.08) : ColorConstants.daylightSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( color: selected ? ColorConstants.primary : ColorConstants.borderSubtle, width: selected ? 1.6 : 1, ), ), child: Column( children: [ Icon( m.icon, size: 20.sp, color: selected ? ColorConstants.primary : ColorConstants.secondaryText, ), SizedBox(height: 5.h), Text( m.label, style: TextStyle( fontSize: 12.5.sp, fontWeight: FontWeight.w700, color: selected ? ColorConstants.primary : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ); } /// The commit. Names the amount, because that is the fact the rider is acting /// on and the one he will be asked for at the door. Widget _payBar() { return Container( padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 12.h), decoration: BoxDecoration( color: ColorConstants.pureSurface, border: Border( top: BorderSide(color: ColorConstants.borderSubtle, width: 1), ), boxShadow: DesignConstants.shadowSm, ), child: PrimaryButton( label: _amount > 0 ? 'Pay $_amountLabel' // A prepaid or zero-rated shipment still has to be created; the // button says what it does rather than asking for money nobody owes. : 'Create order', loading: _busy, onPressed: _busy ? null : _confirm, ), ); } Widget _row(String label, String value, {String? note}) { return Padding( padding: EdgeInsets.symmetric(vertical: 7.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( label, style: TextStyle( fontSize: 12.5.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), Text( value.isEmpty ? '—' : value, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ], ), if (note != null) Padding( padding: EdgeInsets.only(top: 2.h), child: Text( note, style: TextStyle( fontSize: 10.5.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } Widget _divider() => Divider(height: 1.h, color: ColorConstants.borderSubtle); // ── Success ──────────────────────────────────────────────────────────── // // One moment, three beats, staggered so they read as a sequence rather than // a screen that swapped: the ring opens, the tick stamps down inside it, and // the words arrive last. All of it drives off one controller, so the timing // cannot drift. Widget _successView() { final tracking = widget.trackingNo(); return Padding( padding: EdgeInsets.symmetric(horizontal: 24.w), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Spacer(), AnimatedBuilder( animation: _successController, builder: (_, _) => SizedBox( width: 132.w, height: 132.w, child: Stack( alignment: Alignment.center, children: [ // The ring, which expands and fades — a single ripple, not a // loop. A repeating pulse would keep asking for attention on // a screen whose job is finished. Transform.scale( scale: 0.6 + (_ringScale.value * 0.55), child: Opacity( opacity: (1 - _ringScale.value).clamp(0.0, 1.0) * 0.35, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: ColorConstants.acceptGreen, ), ), ), ), Transform.scale( scale: _tickScale.value.clamp(0.0, 1.2), child: Container( width: 92.w, height: 92.w, decoration: BoxDecoration( shape: BoxShape.circle, color: ColorConstants.acceptGreen, boxShadow: [ BoxShadow( color: ColorConstants.acceptGreen.withValues( alpha: 0.28, ), blurRadius: 24, offset: const Offset(0, 8), ), ], ), child: Icon( LucideIcons.check, size: 46.sp, color: ColorConstants.onAccent, ), ), ), ], ), ), ), SizedBox(height: 26.h), FadeTransition( opacity: _copyFade, child: Column( children: [ Text( 'Your order has been created', textAlign: TextAlign.center, style: TextStyle( fontSize: 19.sp, fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 8.h), Text( _amount > 0 ? '$_amountLabel collected by ${_method.label}' : 'Shipment created', textAlign: TextAlign.center, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), // The receipt. Real data from `pickup-complete`, so a customer // asking "what's my tracking number?" gets an answer on the // doorstep instead of a phone call to the hub. if (tracking.isNotEmpty) ...[ SizedBox(height: 18.h), Container( padding: EdgeInsets.symmetric( horizontal: 16.w, vertical: 10.h, ), decoration: BoxDecoration( color: ColorConstants.daylightSurface, borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), border: Border.all(color: ColorConstants.borderSubtle), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.truck, size: 15.sp, color: ColorConstants.secondaryText, ), SizedBox(width: 8.w), Text( tracking, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w700, letterSpacing: 0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ], ], ), ), const Spacer(), FadeTransition( opacity: _copyFade, child: Padding( padding: EdgeInsets.only(bottom: 16.h), child: PrimaryButton( label: 'Done', onPressed: () => Navigator.of(context).pop({ // The shape `map.dart` already reads, so the handoff screen, // the compliance stamping and the next-stop list all keep // working untouched. 'outcome': 'completed', 'isDelivery': false, 'paymentMethod': _method.label, 'amountCollected': _amount, }), ), ), ), ], ), ); } }