Files
doormile_customer_app/lib/ui/screens/tracking_screen.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

883 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../../data/app_config.dart';
import '../../state/app_scope.dart';
import '../../state/app_state.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/feedback.dart';
import '../widgets/milestones.dart';
import '../widgets/misc.dart';
import '../widgets/states.dart';
import 'booking/booking_routes.dart';
import 'booking/send_screen.dart';
import 'sheets/cancel_sheet.dart';
/// Live tracking.
///
/// One screen for the whole journey. While a Miler is being matched it shows a
/// radar; once one is assigned it shows their live position, who they are, and
/// how to reach them; after pickup it follows the order to the door.
/// Cancellation is offered only while the backend still allows it.
class TrackingScreen extends StatelessWidget {
const TrackingScreen({super.key, this.focusedDestination});
/// When set, this screen follows one destination's own order rather than the
/// pickup as a whole. Only meaningful once the orders exist.
final int? focusedDestination;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final booking = app.trackedBooking;
if (booking == null) {
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Tracking'),
body: const DmEmptyState(
icon: LucideIcons.package,
title: 'Nothing to track',
message: 'Book a pickup to get started.',
),
);
}
final cancelled = booking.isCancelled;
final slot = app.slotById(booking.slotId);
// A focused destination follows its own journey; the pickup follows its own.
final focused =
(focusedDestination != null &&
focusedDestination! < booking.destinations.length &&
booking.destinations[focusedDestination!].trackingId != null)
? booking.destinations[focusedDestination!]
: null;
final stage = focused?.stage ?? booking.stage;
final milerEnRoute =
!cancelled &&
booking.miler != null &&
stage.index >= JourneyStage.assigned.index &&
stage.index <= JourneyStage.pickedUp.index;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: DmTopBar(
title: cancelled ? 'Booking' : 'Live Order Tracking',
// No reference here. The head card carries it at 26pt, which is what
// a customer reads out over a phone, and the same string twice on one
// screen is one of them doing nothing.
),
body: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () => app.refreshBooking(booking.reference),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
_TrackCard(booking: booking, slot: slot, focused: focused),
// ── No map, and no radar ──
//
// The radar was a 200pt card with a pulsing sweep and a bike glyph
// at its centre, beside a banner counting "verified Milers active
// in your zone" — animation standing in for an answer.
//
// The map went for a harder reason. It was 178pt of basemap, two
// labels and a pin, on a screen whose question is "where is my
// parcel" and whose honest answer is a milestone. The pin was the
// *pickup address* — a place the customer chose and already knows
// — not the Miler, who only has a position for the twenty minutes
// between assignment and the door. A picture that cannot show the
// thing it implies invites somebody to stare at a stationary dot
// and conclude nothing is happening.
//
// The live position, when there is one, is a sentence on the
// Miler's own card.
if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _Journey(booking: booking),
),
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _YourPackages(booking: booking, focused: focused),
),
// `_RouteOutline` was here: the same pickup, destination and
// window the head card now draws as a rail, repeated at the foot
// of the screen. It existed when the head was a crimson headline
// that carried no route at all.
// ── Who is carrying it comes last ──
//
// The Miler's card sat directly under the head, between the
// reference and the journey, which put a person between the
// customer and the answer they opened the screen for. It is real
// and it is reachable — a name, a plate, Call and Message — but it
// is *who*, and the screen's question is *where*. The journey has
// the middle of the page now and the person is at the foot of it,
// one thumb away.
if (milerEnRoute)
_MilerCard(person: booking.miler!),
if (!cancelled &&
stage == JourneyStage.outForDelivery &&
booking.deliveryAgent != null)
_MilerCard(person: booking.deliveryAgent!),
// Stands in for backend push updates. Real stage changes arrive
// from `GET /customer/bookings/{reference}`, so this exists only
// where there is no server to send them.
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
],
),
),
// No bed under the actions. `edge: true` painted a white bar with a
// hairline across the bottom, which on this canvas reads as a second
// surface holding two buttons — and the buttons already have edges.
bottomNavigationBar: DmFooter(
children: _footerActions(context, app, booking),
),
);
}
/// How far along the route the Miler is drawn, by stage.
/// The footer is a hierarchy, not a single button: the helpful action is the
/// filled one and cancelling is a text button under it. Cancel used to be a
/// full-width outlined button in the danger colour — the most prominent
/// control on a screen whose job is to reassure someone that their parcel is
/// fine.
List<Widget> _footerActions(
BuildContext context,
AppState app,
Booking booking,
) {
if (booking.isCancelled || booking.stage == JourneyStage.delivered) {
return [
DmButton(
label: 'Book another pickup',
icon: LucideIcons.arrowRight,
onPressed: () {
app.startBooking();
Navigator.of(context).pushAndRemoveUntil(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
(route) => route.isFirst,
);
},
),
];
}
return [
// Filled, not outlined. Sharing a live link is the only thing a
// customer can *do* from this screen while the parcel is moving, and an
// outlined button on a page of white cards was the quietest object on
// it. Cancelling stays a text action, which is the right weight for
// something you should not reach for by accident.
DmButton(
label: 'Share live tracking',
icon: LucideIcons.share2,
iconLeading: true,
onPressed: () => DmToast.show(context, 'Sharing is coming soon'),
),
// Backend policy decides whether cancelling is still possible.
if (app.isCancellable(booking.stage))
DmButton(
label: 'Cancel this pickup',
kind: DmButtonKind.ghost,
danger: true,
onPressed: () async {
final cancelled = await showCancelSheet(context, booking);
if (cancelled == true && context.mounted) {
DmToast.show(context, 'Pickup cancelled');
}
},
)
else
DmButton(
label: 'Need help with this order?',
kind: DmButtonKind.ghost,
onPressed: () =>
DmToast.show(context, 'Support chat is not available yet'),
),
];
}
}
/// The state, the route, and the shipment's own figures.
///
/// ── Card → bare type → this ──
///
/// The bordered card repeated the milestone the journey below it already
/// listed. Stripping it to type fixed that and left the top of the screen with
/// nothing on it but two words and two places.
///
/// The reference's answer is better than both: the state stays the hero, but
/// it earns a row of its own with the reference and a priority mark beside it,
/// the promise sits under it, and the route becomes a card carrying the two
/// ends *and* the three figures a customer checks while waiting — how far,
/// what is carrying it, how heavy.
class _TrackCard extends StatelessWidget {
const _TrackCard({required this.booking, required this.slot, this.focused});
final Booking booking;
final PickupSlot? slot;
/// Set when following one destination's own order.
final DestinationGroup? focused;
@override
Widget build(BuildContext context) {
final cancelled = booking.isCancelled;
final stage = focused?.stage ?? booking.stage;
final group = focused ?? booking.destinations.first;
// What the customer can act on: when, before the handover; where, after.
final when = switch (true) {
_ when cancelled => 'You were not charged',
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
'Arriving in about ${booking.milerEtaMinutes} min',
_ when stage == JourneyStage.arrived => 'At your door now',
_ when stage.index <= JourneyStage.assigned.index && slot != null =>
'Today · ${slot!.window}',
_ when booking.expectedDelivery != null =>
'Expected ${booking.expectedDelivery}',
_ => null,
};
final eta = booking.milerEtaMinutes;
final reference = focused?.trackingId ?? booking.displayId;
// Only figures the backend actually sent. A strip with one cell in it is
// a fact centred in a grey band, which is worse than the fact on its own
// — so it is drawn at two or more.
final figures = <Widget>[
if (booking.fare?.routeKm != null)
_Figure(
label: 'Distance',
value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
),
_Figure(
label: booking.totalPackages == 1 ? 'Package' : 'Packages',
value: '${booking.totalPackages}',
),
if (booking.totalWeightLabel != null)
_Figure(label: 'Weight', value: booking.totalWeightLabel!)
else if (booking.miler?.vehicleType != null)
_Figure(label: 'Vehicle', value: booking.miler!.vehicleType!)
else if (slot != null)
_Figure(label: 'Window', value: slot!.window),
];
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 2, DmSpace.pad, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
DmStatusChip(
label: reference.startsWith('#') ? reference : '#$reference',
tone: cancelled ? DmColors.ink4 : DmColors.brand,
quiet: cancelled,
),
// No priority chip. The reference carries "EXPRESS PRIORITY"
// beside the reference; this backend has no express tier, and a
// badge that every booking wears is decoration.
],
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
cancelled ? 'Cancelled' : stage.milestoneLabel,
style: DmText.headerTitle.copyWith(
fontSize: 29,
color: cancelled ? DmColors.ink3 : DmColors.ink,
),
),
),
if (eta != null && eta > 0 && !cancelled) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.fromLTRB(11, 7, 11, 8),
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.chip),
),
child: Text(
'ETA $eta mins',
style: DmText.label.copyWith(
fontSize: 12.5,
color: Colors.white,
),
),
),
],
],
),
if (when != null) ...[
const SizedBox(height: 5),
Text(
when,
style: DmText.body.copyWith(fontSize: 14.5, color: DmColors.ink3),
),
],
const SizedBox(height: 16),
DmCard(
raised: !cancelled,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _Place(
label: 'ORIGIN',
value: booking.pickup.title,
note: booking.pickup.sub,
),
),
const SizedBox(width: 16),
Expanded(
child: _Place(
label: 'DESTINATION',
value: group.destination.shortLabel,
note: group.destination.stateName,
end: true,
),
),
],
),
),
if (figures.length > 1)
DmCardCell(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 13),
// `groove`, not `surfaceAlt`: the latter is within a point
// of the canvas, so the strip read as spilling out of the
// bottom of the card rather than as a band inside it.
background: DmColors.groove,
child: Row(children: figures),
),
],
),
],
),
);
}
}
/// A labelled place. Two of these are the route.
class _Place extends StatelessWidget {
const _Place({
required this.label,
required this.value,
this.note,
this.end = false,
});
final String label;
final String value;
final String? note;
/// The destination, which aligns to the card's right edge so the two ends
/// read as two ends rather than as two columns of a table.
final bool end;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment:
end ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: DmText.eyebrow),
const SizedBox(height: 5),
Text(
value,
textAlign: end ? TextAlign.right : TextAlign.left,
style: DmText.cardTitle.copyWith(fontSize: 17),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (note != null && note!.isNotEmpty)
Text(
note!,
textAlign: end ? TextAlign.right : TextAlign.left,
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
// Two lines. A pickup note is a locality and a pincode, and at one
// line it rendered "Gandhipuram, Coimbat…" — which is the half of
// it a customer already knows.
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
/// One of the three figures in the strip under the route.
class _Figure extends StatelessWidget {
const _Figure({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label.toUpperCase(),
style: DmText.eyebrow.copyWith(fontSize: 9.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
value,
style: DmText.cardTitle.copyWith(fontSize: 14.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
/// The journey. All of it, always.
///
/// It used to show the three most recent milestones behind a "Full timeline"
/// action, which was the right compression for seven and is the wrong one for
/// five: the whole shipment now fits in the space the summary occupied, and a
/// customer who opens tracking to ask "where is my parcel" should not have to
/// tap to see the answer's context.
class _Journey extends StatelessWidget {
const _Journey({required this.booking});
final Booking booking;
@override
Widget build(BuildContext context) {
return DmMilestones(
booking: booking,
title: 'Trip milestones',
showStep: true,
);
}
}
/// Who is collecting, and how to reach them.
///
/// ── Card → bare row → card ──
///
/// The first card was a bordered box with an avatar, a name, a plate tag and
/// a two-cell footer holding a filled crimson Call and an outlined Message —
/// six objects to say "Arun is coming, here is his number". Stripping it to a
/// row under a hairline fixed the clutter and lost the person: the one human
/// being in the whole flow became the quietest thing on the screen.
///
/// It is a lifted card again, with the contents the flat pass arrived at. The
/// avatar keeps its crimson disc — this is the one place in the app standing
/// in for a person, which is the job the solid tone was written for — and the
/// two actions are a filled Call beside an outlined Message, because a phone
/// number is what this card is *for*.
///
/// "Your Miler" / "Delivery Miler" stayed gone. The two are shown at mutually
/// exclusive stages, so the label distinguished between things that are never
/// on the screen together.
class _MilerCard extends StatelessWidget {
const _MilerCard({required this.person});
final Person person;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 10),
child: DmCard(
raised: true,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
child: Row(
children: [
DmAvatar(initials: person.initials, size: 46),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
person.name,
style: DmText.cardTitle.copyWith(fontSize: 16.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
// ── Reputation, then the plate ──
//
// The reference puts "★ 4.9 · 1,240 deliveries" under
// the name, which is the line that makes a stranger at
// your door a person somebody has vouched for. It is
// shown only when the backend actually sent a rating or
// a count — a default here would be an invented
// reputation on a real person — and the plate takes the
// line whenever it did not.
if (person.rating != null || person.trips != null)
_Credentials(person: person)
else
Text(
person.vehicle,
style: DmText.monoSm.copyWith(color: DmColors.ink3),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
_Reach(
icon: LucideIcons.phone,
tooltip: 'Call ${person.name}',
filled: true,
onPressed: () =>
DmToast.show(context, 'Calling ${person.name}…'),
),
const SizedBox(width: 9),
_Reach(
icon: LucideIcons.messageSquare,
tooltip: 'Message ${person.name}',
onPressed: () =>
DmToast.show(context, 'Chat is not available yet'),
),
],
),
),
],
),
);
}
}
/// Call and Message, as two discs.
///
/// They were full-width buttons in a second cell, which gave a phone call the
/// same footprint as the screen's primary action. A round target beside the
/// name is the shape every app that has ever put a courier on a screen
/// arrived at, and it costs a third of the height.
class _Reach extends StatefulWidget {
const _Reach({
required this.icon,
required this.tooltip,
required this.onPressed,
this.filled = false,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
final bool filled;
@override
State<_Reach> createState() => _ReachState();
}
class _ReachState extends State<_Reach> {
bool _down = false;
@override
Widget build(BuildContext context) {
return Tooltip(
message: widget.tooltip,
child: Semantics(
button: true,
label: widget.tooltip,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapUp: (_) => setState(() => _down = false),
onTapCancel: () => setState(() => _down = false),
onTap: widget.onPressed,
child: AnimatedScale(
scale: _down ? 0.92 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: widget.filled ? DmColors.brand : DmColors.surfaceAlt,
shape: BoxShape.circle,
),
child: Icon(
widget.icon,
size: 19,
color: widget.filled ? Colors.white : DmColors.ink,
),
),
),
),
),
);
}
}
/// The individual orders a completed pickup produced.
///
/// Deliberately absent until the backend has actually created them: before
/// collection the customer has one scheduled visit, not several shipments.
class _YourPackages extends StatelessWidget {
const _YourPackages({required this.booking, this.focused});
final Booking booking;
/// The one being followed, so the list can mark it.
final DestinationGroup? focused;
@override
Widget build(BuildContext context) {
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: Row(
children: [
Expanded(
child: Text(
'YOUR PACKAGES',
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
Flexible(
child: Text(
focused == null
? 'Collected in one visit'
: 'Pickup ${booking.reference}',
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny,
),
),
],
),
),
for (final group in booking.destinations)
_PackageRow(group: group, isFocused: identical(group, focused)),
],
);
}
}
class _PackageRow extends StatelessWidget {
const _PackageRow({required this.group, this.isFocused = false});
final DestinationGroup group;
final bool isFocused;
@override
Widget build(BuildContext context) {
final stage = group.stage;
return DmCardCell(
background: isFocused ? DmColors.brandSoft : null,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(group.destination.label, style: DmText.cardTitle),
const SizedBox(height: 3),
Text.rich(
TextSpan(
style: DmText.tiny,
children: [
TextSpan(
text: group.trackingId ?? '—',
style: DmText.monoSm,
),
TextSpan(
text:
' · ${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
if (stage != null)
Flexible(
child: DmPill(
stage.milestoneLabel,
tone: stage == JourneyStage.delivered
? DmPillTone.done
: DmPillTone.neutral,
),
),
],
),
);
}
}
/// The backend's QA stage helper, as a control.
///
/// It is not a simulator: each tap posts to
/// `POST /customer/ops/bookings/{reference}/stage` and the screen then re-reads
/// the booking, so what it shows is what the **server** recorded. Off entirely
/// unless the build asked for it with `DM_ALLOW_STAGE_OVERRIDE`, and never on
/// production or in a release.
class _StageStepper extends StatelessWidget {
const _StageStepper({required this.booking});
final Booking booking;
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final index = booking.stage.index;
final locked = booking.isCancelled;
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 20),
child: Container(
padding: const EdgeInsets.fromLTRB(16, 10, 10, 10),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
Expanded(
child: Text(
'Move stage (QA)',
style: DmText.label.copyWith(color: DmColors.ink3),
),
),
_StepButton(
icon: LucideIcons.minus,
onTap: locked || index == 0
? null
: () => app.advanceStage(booking, JourneyStage.at(index - 1)),
),
const SizedBox(width: 8),
_StepButton(
icon: LucideIcons.plus,
onTap: locked || index == JourneyStage.values.length - 1
? null
: () => app.advanceStage(booking, JourneyStage.at(index + 1)),
),
],
),
),
);
}
}
class _StepButton extends StatelessWidget {
const _StepButton({required this.icon, this.onTap});
final IconData icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Opacity(
opacity: enabled ? 1 : 0.35,
child: Material(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.xs),
child: InkWell(
borderRadius: DmRadius.all(DmRadius.xs),
onTap: onTap,
child: Ink(
width: 38,
height: 38,
decoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.xs),
border: Border.all(color: DmColors.border),
),
child: Icon(icon, size: 18, color: DmColors.ink2),
),
),
),
);
}
}
/// "★ 4.9 · 1,240 deliveries · TN 37 BX 4412", minus whatever is missing.
class _Credentials extends StatelessWidget {
const _Credentials({required this.person});
final Person person;
@override
Widget build(BuildContext context) {
final rest = [
if (person.trips != null)
'${person.trips} ${person.trips == 1 ? 'delivery' : 'deliveries'}',
if (person.vehicle.isNotEmpty) person.vehicle,
].join(' · ');
return Row(
children: [
if (person.rating != null) ...[
const Icon(LucideIcons.star, size: 12, color: DmColors.brand),
const SizedBox(width: 4),
Text(
person.rating!.toStringAsFixed(1),
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
),
if (rest.isNotEmpty) const SizedBox(width: 7),
],
if (rest.isNotEmpty)
Flexible(
child: Text(
rest,
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}