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>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -0,0 +1,904 @@
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 }