Files
doormile_milderapp/lib/views/Dashboard/pickups/shipment_review.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
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>
2026-08-22 05:40:35 +05:30

703 lines
24 KiB
Dart

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<String, dynamic> pickup;
/// What the shipment desk captured — from, to, weight, zone, price.
final Map<String, dynamic> 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<String?> 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<ShipmentReviewPage> createState() => _ShipmentReviewPageState();
}
class _ShipmentReviewPageState extends State<ShipmentReviewPage>
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<double> _tickScale;
late final Animation<double> _ringScale;
late final Animation<double> _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<String, dynamic> get _from =>
(widget.shipment['from'] as Map?)?.cast<String, dynamic>() ?? {};
Map<String, dynamic> get _to =>
(widget.shipment['to'] as Map?)?.cast<String, dynamic>() ?? {};
Future<void> _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(<String, dynamic>{
// 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,
}),
),
),
),
],
),
);
}
}