Files
doormile_milderapp/lib/views/Dashboard/pickups/shipment_capture.dart
2026-09-09 12:55:23 +05:30

910 lines
32 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/miler_surface.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 your office 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: MilerSurface.canvas,
appBar: milerPageBar('Shipment details'),
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
// ── The bands carry the gutter, the page does not ──
//
// Every child below is full-bleed and pads its own content by
// [_bandPad]. The page used to hold a 16pt margin and give each
// card a border and a radius on top of it, which spent 32 of a
// 390pt phone's width on two strips of grey either side of a
// form whose fields are addresses. The ground showing between
// two bands separates them just as well and costs nothing.
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_journeyCard(),
SizedBox(height: MilerBand.gap),
_parcelCard(),
SizedBox(height: MilerBand.gap),
_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.w600,
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 MilerBand(
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.w900,
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.
/// The inset a full-bleed band keeps around its content. The app's own, so
/// this page and the verification screen that hands off to it line up at the
/// same left margin. See [MilerBand].
double get _bandPad => MilerBand.pad;
Widget _parcelCard() {
return MilerBand(
padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 16.h),
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.w900,
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;
// A priced band goes pale green and takes the rail; an unpriced one stays
// white. Both fills are opaque colours rather than the accent laid over the
// page — see [ColorConstants.tint].
return MilerBand(
color: priced
? ColorConstants.tint(ColorConstants.acceptGreen, 0.06)
: ColorConstants.pureSurface,
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.w900,
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.w800,
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.w900,
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.w700,
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.w600,
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.w600,
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.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
Text(
value,
style: TextStyle(
fontSize: emphasis ? 17.sp : 13.sp,
fontWeight: emphasis ? FontWeight.w900 : FontWeight.w700,
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.w600,
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.w600,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
decoration: InputDecoration(
counterText: '',
hintText: hint,
prefixIcon: Icon(icon, size: 18.sp),
filled: true,
// ── A field on a white card needs a ground of its own ──
//
// This was `pureSurface` — white type on white inside a white card,
// separated from it by a hairline alone. It read as a line drawn on the
// card rather than as somewhere to type, and at arm's length in sun the
// hairline is the first thing to go.
//
// The canvas is the app's own "one step back from a surface" tone, so a
// field wearing it sits *in* the card the way a well does, and the
// hairline becomes the edge of a shape rather than the whole of it.
fillColor: MilerSurface.canvas,
hintStyle: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
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.w700,
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.w700,
color: v == selected
? ColorConstants.onAccent
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
],
),
],
);
}
}
/// Which end of a journey a marker draws.
enum _RouteMarker { origin, destination }