Files
doormile_milderapp/lib/views/Dashboard/pickups/shipment_capture.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

905 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
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:geolocator/geolocator.dart';
import 'package:miler/data/miler_api.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';
/// ─────────────────────────────────────────────────────────────────────────
/// THE SHIPMENT DESK — what a logistics rider settles at the customer's door.
///
/// A logistics booking usually arrives half-known. The customer raised it from
/// a map pin and described the destination as a phone number and a landmark;
/// nobody has weighed anything. The rider standing in the sender's doorway is
/// the first person who can establish any of it, and everything downstream —
/// which hub it routes through, what it costs, what the customer pays now —
/// follows from what he writes here.
///
/// ```
/// FROM ──▶ TO ──▶ WEIGHT ──▶ DISTANCE ──▶ PRICE ──▶ (payment, then initiate)
/// ```
///
/// ── Why the four steps are on one screen ──
///
/// They are not four decisions; they are one conversation, held once, standing
/// up. A wizard would make the rider commit to an address before he has been
/// told the weight, and re-pricing after a correction would mean walking back
/// through pages. Here every field feeds the same quote, and the quote
/// re-computes when any of them changes.
///
/// ── What this screen does NOT do ──
///
/// It does not take money and it does not initiate anything. It hands its
/// capture back to the caller, which sends the addresses, then the parcel
/// measurements, then the payment, and only then `pickup-complete`. That order
/// is the contract's, not a preference: `pickup-complete` converts the booking
/// into a consignment and reads the addresses to route it, so anything written
/// after it has nowhere to land.
///
/// ── And it never invents a fare ──
///
/// The price comes from `POST /pricing/check` against the real pincodes and the
/// real weight. When the table has no rule for a combination the screen says so
/// and refuses to continue, because the alternative — a locally computed number
/// that looks official on a doorstep — is how a rider ends up collecting the
/// wrong money in the company's name.
/// ─────────────────────────────────────────────────────────────────────────
class ShipmentCapturePage extends StatefulWidget {
final Map<String, dynamic> pickup;
const ShipmentCapturePage({super.key, required this.pickup});
@override
State<ShipmentCapturePage> createState() => _ShipmentCapturePageState();
}
class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
// ── FROM ──
final _fromAddress = TextEditingController();
final _fromPincode = TextEditingController();
// ── TO ──
final _toAddress = TextEditingController();
final _toPincode = TextEditingController();
final _toCity = TextEditingController();
// ── The shipment ──
final _weight = TextEditingController();
String _serviceType = 'Normal';
String _category = 'General';
/// The rider's fix at the door, so the FROM pin is where he is standing
/// rather than where the customer dropped a marker last week.
double? _fromLat;
double? _fromLon;
ShipmentQuote? _quote;
bool _quoting = false;
String? _quoteError;
/// Debounces the quote: the weight field re-fires on every keystroke and
/// "1", "1.", "1.2" are three requests for one number.
Timer? _quoteDebounce;
@override
void initState() {
super.initState();
final d = widget.pickup;
// Seeded from the booking so the rider is confirming, not transcribing.
// Most of the time the customer's address is right and he changes nothing.
_fromAddress.text = (d['pickupaddress'] ?? d['PickupAddress'] ?? '')
.toString();
_fromPincode.text = (d['pickuppincode'] ?? '').toString();
_toAddress.text = (d['dropaddress'] ?? d['DropAddress'] ?? '').toString();
_toPincode.text = (d['droppincode'] ?? '').toString();
_fromLat = double.tryParse('${d['pickuplat'] ?? ''}');
_fromLon = double.tryParse('${d['pickuplon'] ?? ''}');
_captureRiderPosition();
for (final c in [_fromPincode, _toPincode, _weight]) {
c.addListener(_scheduleQuote);
}
}
@override
void dispose() {
_quoteDebounce?.cancel();
for (final c in [
_fromAddress,
_fromPincode,
_toAddress,
_toPincode,
_toCity,
_weight,
]) {
c.dispose();
}
super.dispose();
}
/// The FROM pin, taken where the rider is standing.
///
/// Best-effort: a failed fix leaves the booking's own coordinates in place,
/// which is what the app had before. Never blocks the form.
Future<void> _captureRiderPosition() async {
try {
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 6),
),
).timeout(const Duration(seconds: 7));
if (!mounted) return;
setState(() {
_fromLat = pos.latitude;
_fromLon = pos.longitude;
});
} catch (_) {
// Keep whatever the booking carried.
}
}
double get _weightKg => double.tryParse(_weight.text.trim()) ?? 0;
/// Pincodes are what the server derives the zone from, so a quote is
/// impossible without both — and a six-digit one is the only shape that can
/// be one.
bool get _canQuote =>
_weightKg > 0 &&
_fromPincode.text.trim().length == 6 &&
_toPincode.text.trim().length == 6;
void _scheduleQuote() {
_quoteDebounce?.cancel();
if (!_canQuote) {
if (_quote != null || _quoteError != null) {
setState(() {
_quote = null;
_quoteError = null;
});
}
return;
}
_quoteDebounce = Timer(const Duration(milliseconds: 600), _fetchQuote);
}
Future<void> _fetchQuote() async {
if (!_canQuote || _quoting) return;
setState(() {
_quoting = true;
_quoteError = null;
});
final res = await MilerApi.checkPrice(
weight: _weightKg,
serviceType: _serviceType,
pickupPincode: _fromPincode.text.trim(),
deliveryPincode: _toPincode.text.trim(),
category: _category,
);
if (!mounted) return;
if (!res.ok) {
setState(() {
_quoting = false;
_quote = null;
// The network failed, which is a "try again" — distinct from the table
// having no rule, which is not.
_quoteError = res.message.isEmpty
? 'Could not reach pricing. Check your connection and retry.'
: res.message;
});
return;
}
final quote = ShipmentQuote.fromData(res.map, preferredCategory: _category);
setState(() {
_quoting = false;
_quote = quote.found ? quote : null;
_quoteError = quote.found
? null
: 'No price is configured for this weight and route. '
'Call the hub before collecting.';
});
}
/// ── The straight-line distance between the two ends ──
///
/// Shown, not charged. The fare comes from the pricing table's zone, which
/// the server derives from the pincodes; this is the sanity check that tells
/// a rider he has typed a destination pincode from the wrong city before he
/// takes money for it. Labelled as approximate for exactly that reason —
/// claiming a road distance the app has not measured would be a worse lie
/// than showing none.
double? get _approxKm {
final toLat = double.tryParse('${widget.pickup['droplat'] ?? ''}');
final toLon = double.tryParse('${widget.pickup['droplon'] ?? ''}');
if (_fromLat == null || _fromLon == null) return null;
if (toLat == null || toLon == null || toLat == 0 || toLon == 0) return null;
return Geolocator.distanceBetween(_fromLat!, _fromLon!, toLat, toLon) /
1000.0;
}
bool get _isComplete =>
_fromAddress.text.trim().isNotEmpty &&
_fromPincode.text.trim().length == 6 &&
_toAddress.text.trim().isNotEmpty &&
_toPincode.text.trim().length == 6 &&
_weightKg > 0 &&
_quote != null;
/// What is still missing, named. A disabled button with no reason is the
/// thing riders phone the hub about.
String get _blockingReason {
if (_fromAddress.text.trim().isEmpty) return 'Confirm the pickup address';
if (_fromPincode.text.trim().length != 6) return 'Pickup pincode needed';
if (_toAddress.text.trim().isEmpty) return 'Enter the delivery address';
if (_toPincode.text.trim().length != 6) return 'Delivery pincode needed';
if (_weightKg <= 0) return 'Weigh the parcel';
if (_quoting) return 'Getting the price…';
if (_quoteError != null) return _quoteError!;
return 'Waiting for the price';
}
void _submit() {
if (!_isComplete) {
AppFeedback.info(context, _blockingReason);
return;
}
final q = _quote!;
Navigator.pop(context, <String, dynamic>{
'captured': true,
'from': {
'address': _fromAddress.text.trim(),
'pincode': _fromPincode.text.trim(),
'lat': _fromLat,
'lon': _fromLon,
},
'to': {
'address': _toAddress.text.trim(),
'pincode': _toPincode.text.trim(),
'city': _toCity.text.trim(),
'lat': widget.pickup['droplat'],
'lon': widget.pickup['droplon'],
},
'weight': _weightKg,
'category': _category,
'servicetype': _serviceType,
'zone': q.zone,
// The figure the customer is about to be asked for. The payment screen
// takes it from here so the two can never disagree about the amount.
'price': q.payable,
'pricemin': q.minPrice,
'pricemax': q.maxPrice,
'currency': q.currency,
'approxkm': _approxKm,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.daylightSurface,
appBar: milerPageBar('Shipment details'),
body: SafeArea(
child: 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: [
_journeyCard(),
SizedBox(height: 14.h),
_parcelCard(),
SizedBox(height: 14.h),
_quotePanel(),
],
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_isComplete)
Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Text(
_blockingReason,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
PrimaryButton(
label: _quote == null
? 'Continue to payment'
: 'Continue · ${_quote!.currency} '
'${_quote!.payable.toStringAsFixed(0)}',
onPressed: _isComplete ? _submit : null,
),
],
),
),
],
),
),
);
}
/// ─────────────────────────────────────────────────────────────────────
/// THE JOURNEY, AS ONE OBJECT
///
/// From and To were two headed groups of loose white fields on a grey page —
/// address, pincode, address, pincode, city — five pills with nothing
/// relating them. The rider reading it back to a customer had to hold the
/// pairing in his head, and the one fact the screen is actually about, that
/// this parcel goes from *here* to *there*, was never drawn.
///
/// So it is drawn: one surface, one rail, a dot at the origin and a pin at
/// the destination, with each end's fields grouped under its own marker.
/// This is the shape every logistics app in the world uses for a journey,
/// and it is the shape because it is read in one glance.
/// ─────────────────────────────────────────────────────────────────────
Widget _journeyCard() {
return Container(
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_leg(
marker: _RouteMarker.origin,
label: 'FROM',
children: [
_field(
controller: _fromAddress,
hint: 'Pickup address',
icon: LucideIcons.house,
maxLines: 2,
),
SizedBox(height: 8.h),
_field(
controller: _fromPincode,
hint: 'Pincode',
icon: LucideIcons.mailbox,
keyboardType: TextInputType.number,
maxLength: 6,
digitsOnly: true,
),
],
),
_leg(
marker: _RouteMarker.destination,
label: 'TO',
children: [
_field(
controller: _toAddress,
hint: 'Delivery address',
icon: LucideIcons.mapPin,
maxLines: 2,
),
SizedBox(height: 8.h),
Row(
children: [
Expanded(
child: _field(
controller: _toPincode,
hint: 'Pincode',
icon: LucideIcons.mailbox,
keyboardType: TextInputType.number,
maxLength: 6,
digitsOnly: true,
),
),
SizedBox(width: 8.w),
Expanded(
child: _field(
controller: _toCity,
hint: 'City',
icon: LucideIcons.building2,
),
),
],
),
],
),
],
),
);
}
/// One end of the journey: its marker, its name, its fields.
///
/// The rail is drawn by the marker column rather than behind the row, so it
/// stretches to whatever height the fields take — an address that wraps to
/// two lines lengthens the line instead of breaking it.
Widget _leg({
required _RouteMarker marker,
required String label,
required List<Widget> children,
}) {
final origin = marker == _RouteMarker.origin;
final tint = origin
? ColorConstants.pickupAccent
: ColorConstants.deliveryAccent;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 26.w,
child: Column(
children: [
SizedBox(height: 3.h),
origin
? Container(
width: 11.w,
height: 11.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: tint, width: 3),
),
)
: Icon(LucideIcons.mapPin, size: 15.sp, color: tint),
if (origin)
Expanded(
child: Container(
width: 2,
margin: EdgeInsets.symmetric(vertical: 4.h),
color: ColorConstants.borderStrong,
),
),
],
),
),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: origin ? 14.h : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
color: tint,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 8.h),
...children,
],
),
),
),
],
),
);
}
/// What is being sent — weight, what it is, how fast.
///
/// One surface rather than a heading and three loose blocks, for the same
/// reason as the journey above: these three answers are one description of
/// one parcel, and the price is computed from all three together.
Widget _parcelCard() {
return Container(
padding: EdgeInsets.fromLTRB(14.w, 15.h, 14.w, 16.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.package,
size: 15.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 7.w),
Text(
'THE PARCEL',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
SizedBox(height: 12.h),
_field(
controller: _weight,
hint: 'Weight (kg)',
icon: LucideIcons.weight,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
SizedBox(height: 12.h),
_chips(
label: 'Category',
values: MilerApi.parcelCategories,
selected: _category,
onSelect: (v) {
setState(() => _category = v);
_scheduleQuote();
},
),
SizedBox(height: 12.h),
_chips(
label: 'Service',
values: MilerApi.serviceTypes,
selected: _serviceType,
onSelect: (v) {
setState(() => _serviceType = v);
_scheduleQuote();
},
),
],
),
);
}
/// The number the rider says out loud, at the size he can read it at.
///
/// ── Why the total is not a table row ──
///
/// This was four `label · value` rows of equal weight — distance, zone,
/// chargeable weight, price — with the price differing only by a bold. But
/// three of those exist to *explain* the fourth, and the fourth is the only
/// one that gets spoken to a customer. Read at arm's length on a doorstep,
/// a bolded 13sp among three 13sp rows is not a total.
///
/// So the money leads at display size, on the accent, and the workings sit
/// under it as the supporting detail they are.
Widget _quotePanel() {
final km = _approxKm;
final quote = _quote;
final priced = quote != null && !_quoting && _quoteError == null;
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h),
decoration: BoxDecoration(
color: priced
? ColorConstants.acceptGreen.withValues(alpha: 0.06)
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: priced
? ColorConstants.acceptGreen.withValues(alpha: 0.28)
: ColorConstants.borderSubtle,
width: 1.2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.receiptText,
size: 15.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 7.w),
Text(
'WHAT THE CUSTOMER PAYS',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
SizedBox(height: 12.h),
if (priced) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
quote.currency,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 3.w),
Flexible(
child: Text(
quote.payable.toStringAsFixed(0),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 34.sp,
height: 1.05,
fontWeight: FontWeight.w800,
letterSpacing: -1.2,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
if (quote.isRange) ...[
SizedBox(height: 2.h),
Text(
'Band ${quote.minPrice.toStringAsFixed(0)}–'
'${quote.maxPrice.toStringAsFixed(0)} · charging the lower',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
SizedBox(height: 12.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 10.h),
_row('Zone', quote.zone),
SizedBox(height: 6.h),
_row('Chargeable weight', '${quote.weight} kg'),
SizedBox(height: 6.h),
],
_row(
'Distance',
km == null ? 'Not available' : '≈ ${km.toStringAsFixed(1)} km',
// Said out loud rather than left for the rider to assume: it is a
// straight line between two pins, not a road distance, and the
// fare does not come from it.
note: km == null ? null : 'straight line — the fare uses the zone',
),
if (!priced) SizedBox(height: 12.h),
if (_quoting)
Row(
children: [
SizedBox(
width: 14.w,
height: 14.w,
child: const CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10.w),
Text(
'Getting the price…',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
)
else if (_quoteError != null)
InfoBanner(
icon: LucideIcons.circleAlert,
text: _quoteError!,
color: ColorConstants.errorRed,
)
else if (_quote == null)
Text(
'Enter both pincodes and the weight to get a price.',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
Widget _row(
String label,
String value, {
bool emphasis = false,
String? note,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
Text(
value,
style: TextStyle(
fontSize: emphasis ? 17.sp : 13.sp,
fontWeight: emphasis ? FontWeight.w800 : FontWeight.w600,
color: emphasis
? ColorConstants.primary
: 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 _field({
required TextEditingController controller,
required String hint,
required IconData icon,
TextInputType? keyboardType,
int maxLines = 1,
int? maxLength,
bool digitsOnly = false,
}) {
return TextField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
maxLength: maxLength,
inputFormatters: digitsOnly
? [FilteringTextInputFormatter.digitsOnly]
: null,
onChanged: (_) => setState(() {}),
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
decoration: InputDecoration(
counterText: '',
hintText: hint,
prefixIcon: Icon(icon, size: 18.sp),
filled: true,
fillColor: ColorConstants.pureSurface,
hintStyle: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.borderSubtle),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.borderSubtle),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.primary, width: 1.4),
),
),
);
}
Widget _chips({
required String label,
required List<String> values,
required String selected,
required ValueChanged<String> onSelect,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Wrap(
spacing: 6.w,
runSpacing: 6.h,
children: [
for (final v in values)
GestureDetector(
onTap: () => onSelect(v),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 7.h,
),
decoration: BoxDecoration(
color: v == selected
? ColorConstants.primary
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
border: Border.all(
color: v == selected
? ColorConstants.primary
: ColorConstants.borderSubtle,
),
),
child: Text(
v,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: v == selected
? ColorConstants.onAccent
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
],
),
],
);
}
}
/// Which end of a journey a marker draws.
enum _RouteMarker { origin, destination }