Files
doormile_customer_app/lib/ui/screens/home_screen.dart
Thiru-tenext c7a74c57b8 BOOK NOW, OpenStreetMap, and one segmented control instead of two
── The circle says what pressing it does ──

"ONE TOUCH" named the *mechanism* — one gesture, no form — which is something
the product team knows and a customer has to be taught. Nobody arrives at that
screen wanting a touch; they want a parcel collected. The caption under the
sphere still carries what makes it different from the form below.

Renamed in the comments too. A codebase explaining "One Touch" against a button
that says BOOK NOW is a trap for whoever reads it next.

── OpenStreetMap everywhere ──

One line: the default provider was CARTO, it is `osm`. Nothing else moves —
`DmMapTiles` already reads the template, subdomains, retina flag and
attribution off the provider, so the credit line follows on its own.

One thing recorded on the provider itself rather than left to be discovered:
these are donated servers and the OSM Foundation's tile policy does not permit
a distributed app to lean on them. A block looks like every tile turning into
the ground colour at once, with no other symptom. Moving off it is one define —
`DM_MAP_PROVIDER=carto|maptiler|stadia`, all serving OpenStreetMap data — and
the map_config test now asserts the identifying User-Agent rather than only the
URL, because that is what attributable traffic depends on.

── Orders had a second copy of the segmented control ──

Its own `_Tab`, a pill radius, 3pt of padding and the count folded into the
label's text — beside the pickup window's day switcher, which is DmChoiceChip
in a rounded groove with 4pt of padding and the count in a bubble. Two controls
doing one job, drifting apart a padding value at a time.

It is the same control now, and `_Tab` is gone. DmChoiceChip's horizontal
padding drops 12 → 9: three of them split a 390pt phone and "Cancelled"
truncated to "Cancell…" at the old value. The day switcher has two chips and
acres of room, so it loses nothing.
2026-09-30 11:19:30 +05:30

904 lines
36 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../data/models.dart';
import '../../state/app_scope.dart';
import '../tokens.dart';
import 'shell_screen.dart';
import '../format.dart';
import '../widgets/cards.dart';
import '../widgets/book_orb.dart';
import '../widgets/misc.dart';
import 'booking/booking_routes.dart';
import 'booking/send_screen.dart';
import 'sheets/drop_address_sheet.dart';
import 'sheets/pickup_sheet.dart';
import 'sheets/place_search_sheet.dart';
import 'tracking_screen.dart';
/// Home — two questions, and nothing else on the screen.
///
/// *Can I book?* The sphere.
/// *Is anything happening?* One row, when there is.
///
/// ── What was removed ──
///
/// A two-line caption under the sphere that a returning customer had read a
/// hundred times; a four-cell card for the live booking carrying a status pill,
/// a reference, a route rail, a courier sentence, a window and a Track button —
/// six facts where the customer wanted two; and a three-row explainer with a
/// subtitle under every row.
///
/// The caption is first-run only now and one line. The live booking is one row.
/// The explainer lost its subtitles. Home went from roughly sixty words to
/// under twenty for a new customer, and to five for a returning one.
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
/// True while the destination sheet is up.
///
/// Home does not just sit behind the scrim — it scales back a little and
/// fades, so the sheet reads as having been pushed up by the sphere rather
/// than as a new screen that arrived over the top of an unrelated one.
bool _handingOver = false;
/// BOOK. The one action on this screen, and the whole flow's front door.
///
/// ── One sheet, then the review ──
///
/// It was two sheets in sequence — destination, then window — with a dismiss
/// between them. [showPickupSheet] asks both on one surface that never
/// leaves, which is what the old comment here hoped the sequence would look
/// like and it never did.
///
/// The full form asks for doors afterwards rather than in the middle: the
/// two questions every booking needs are answered first and together, and
/// the addresses — which only the detailed path wants — come last, next to
/// the review that shows them.
Future<void> _book({bool detailed = false}) async {
final app = AppScope.read(context);
final navigator = Navigator.of(context);
setState(() => _handingOver = true);
try {
final asked = await showPickupSheet(context);
if (asked == null || asked.places.isEmpty || !mounted) return;
app.startBooking(detailed: detailed);
app.setDestinations(asked.places);
// ── The slot is re-selected, not assumed ──
//
// [WindowPicker] writes it as the customer taps, which is right when it
// is opened on its own from Review. Opened as the second step of the
// pickup sheet it writes it *before* the draft exists, and
// `startBooking` clears `draftSlotId` — so the booking arrived at Review
// with no window and a disabled button. Writing it after the reset is
// the only ordering that holds for both callers.
app.selectSlot(asked.slot);
// The full form asks for the door before it asks for the window, and it
// asks once per destination: a visit that fans out to three places is
// three addresses, and "for all parcels" is the whole point of choosing
// this path over BOOK NOW.
if (detailed) {
for (final group in app.draftDestinations) {
if (!mounted) return;
final saved = await showDropAddressSheet(
context,
details: group.details,
district: group.destination.shortLabel,
);
if (saved != true) break;
}
}
if (!mounted) return;
await navigator.push(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
} finally {
if (mounted) setState(() => _handingOver = false);
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
// The sphere's own field, floored so a very short window leaves it a
// sphere rather than a slot. It is a *minimum*: the scroll view hands it
// whatever is left once the card below has taken what it needs.
// Lower than it was. The sphere gained a second control under it and the
// live card was sitting a third of a screen below both; the field only has
// to be big enough for the sphere to float in, not to push everything else
// off the fold.
final orbHeight = math.max(260.0, MediaQuery.sizeOf(context).height * 0.30);
return SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// No wordmark bar here.
//
// Orders and Account open on a title and want a bar over it. Home
// opens on the customer's own name with one object under it, and a
// second header above that made the screen start twice.
// ── Greeting, then where from ──
//
// "Hello, Joe ●" with the brand dot, the avatar opposite it, and the
// address as a bordered pill under both. With the wordmark bar gone
// this is the top of the screen, so it takes the inset the bar used
// to hold.
Padding(
// 18, not 26. The inset was set when the greeting was one line
// with nothing under it; the pill below it now carries its own
// height, and 26 on top of that started the screen twice.
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 18, DmSpace.pad, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
'Hello, ${_firstName(app.customer?.name)}',
style: DmText.headerTitle.copyWith(fontSize: 26),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
),
],
),
),
const SizedBox(width: 12),
InkResponse(
onTap: () => ShellScreen.of(context)?.goToTab(2),
radius: 26,
child: DmAvatar(
initials: initialsOf(app.customer?.name),
size: 40,
badge: active != null,
),
),
],
),
const SizedBox(height: 12),
_PickupLine(
area: _areaOf(app.pickup),
resolving: app.resolvingPickup,
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) app.setPickup(place);
},
),
],
),
),
// ── What is running comes first ──
//
// The live card sat under the sphere, at the foot of the screen. A
// customer with a parcel in the network opens this app to check on
// it, not to send another one — so it is the first thing under their
// own name, and the sphere is what they scroll past it to.
if (active != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
14,
DmSpace.pad,
0,
),
child: _LiveCard(booking: active),
)
.animate()
.fadeIn(duration: 380.ms, delay: 160.ms)
.slideY(begin: -0.08, curve: DmMotion.ease),
Expanded(
child: AnimatedScale(
scale: _handingOver ? 0.96 : 1,
duration: DmMotion.base,
curve: DmMotion.ease,
child: AnimatedOpacity(
opacity: _handingOver ? 0.45 : 1,
duration: DmMotion.base,
curve: DmMotion.ease,
child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () async {
await Future.wait([
app.detectPickupLocation(),
app.refreshOrders(),
]);
},
child: LayoutBuilder(
builder: (context, box) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
0,
),
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(orbHeight, box.maxHeight),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// ── What each way asks for ──
//
// Two ways to book the same parcel is not
// something a customer can infer from a sphere
// and a form. One line each, in place: this one
// under the sphere, the other on the DROP row.
// Said side by side in a paragraph they would be
// a comparison nobody reads; said where the
// choice is made they are two captions.
DmBookOrb(
onTap: _book,
held: _handingOver,
// ── The caption used to fall off ──
//
// The sphere's glow field was a constant
// 320, the caption sat under it inside a
// scroll viewport, and every time Home
// grew — a live card, a pickup pill — the
// viewport clipped the caption in silence.
//
// The field is now whatever is left once
// the caption has its line. See
// [DmBookOrb.field]; it clamps itself, so
// a very short screen shrinks the glow
// rather than eating the sentence.
field: box.maxHeight - _captionRoom,
// Short enough to sit on one line, and
// written to be read against the DROP
// row's note rather than on its own:
//
// Just a city and a time
// Enter the full address yourself
//
// Two captions, same length, opposite
// answers. A paragraph comparing them
// would say more and be read by nobody.
caption: 'Just a city and a time',
)
.animate()
.fadeIn(duration: 420.ms)
.scale(
begin: const Offset(0.82, 0.82),
end: const Offset(1, 1),
duration: 620.ms,
curve: Curves.easeOutBack,
),
// ── Where we deliver, said before anybody
// commits ──
//
// A customer cannot tell from a sphere whether
// Doormile goes where their parcel needs to go,
// and the only place that answered it was two
// taps inside the booking flow. One line says
// it, and tapping it opens the same list the
// booking uses — the answer itself rather than a
// second screen repeating it.
//
// Inside the scroll column, not pinned under it.
// Pinned, it took 32 points Home did not have
// and overflowed the fold by 11. In here its
// room is reserved out of the sphere's glow by
// [_captionRoom], which is the mechanism that
// already stops the caption falling off — so the
// sphere gives way and nothing is clipped.
_ReachLine(onTap: () => _book()),
],
),
),
],
),
),
),
),
),
),
// ── Everything under the sphere is pinned ──
//
// The scroll view centres the sphere in whatever height is left, and
// it cannot know about anything laid out after it — so anything laid
// out inside that column pushed the thing before it under the fold.
// The form sits outside it, and the sphere floats in what remains.
//
// A "3 min pickup" chip used to sit here. It was riding the sphere's
// lower arc — the field is centred on the disc, not on the glow
// round it, so a chip pinned directly under the column overlapped
// the shadow the sphere throws.
Padding(
// 14 on top: the sphere's caption ends where this begins, and with
// nothing between them the sentence read as the card's heading.
// It costs the sphere 14 points of glow and nothing else — the
// field is whatever is left over now. See [DmBookOrb.field].
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 10, DmSpace.pad, 0),
child: _PickDropForm(
pickup: app.pickup?.title,
onPickup: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) app.setPickup(place);
},
onDrop: () => _book(detailed: true),
).animate().fadeIn(duration: 380.ms, delay: 300.ms),
),
// Clears the floating tab bar — see [dmTabBarClear], which also
// records why this used to be twice as tall as it needed to be.
SizedBox(height: dmTabBarClear(context)),
],
),
);
}
/// What sits under the sphere inside the scroll column — its caption, and
/// the reach line — plus their gaps.
///
/// Reserved out of the sphere's field rather than laid out after it and
/// hoped for. Anything added below the sphere has to be counted here or it
/// will be the thing that falls off the bottom, silently, which has now
/// happened often enough to be the rule rather than the exception.
///
/// Reserved whether or not the reach line renders. When it does not — the
/// first seconds of a cold start, before the serviceable cities land — the
/// sphere simply has room to spare, which nobody can see. A field that grew
/// the moment the cities arrived would be a sphere that resized itself on
/// screen for no reason the customer could name.
static const _captionRoom = 28.0 + _ReachLine.height;
static String _firstName(String? name) {
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
return first.isEmpty ? 'there' : first;
}
/// The locality the pickup sits in — `Gandhipuram, Coimbatore 641012` — as
/// distinct from the door, which is [Place.title].
///
/// ── Why the pill and the form do not print the same string ──
///
/// Both are the pickup, and if both said `12 Nehru Street` the screen would
/// be answering one question twice, four hundred points apart. They are not
/// the same question. The pill under the customer's name is *where Doormile
/// is serving you* — the thing you check at a glance and change when you
/// have travelled. The PICKUP row in the form is *the door a Miler rings*,
/// and it wants the number and the street.
///
/// So the pill takes the area line and the form keeps the door. When the
/// geocoder sends no area, the door is better than an empty pill.
static String? _areaOf(Place? place) {
if (place == null) return null;
final sub = place.sub.trim();
return sub.isEmpty ? place.title : sub;
}
}
/// "Delivering to 11 cities across 3 states" — the reach, in one line.
///
/// ── Why this is not a page ──
///
/// The question it answers is binary: *do you go where I need?* A customer who
/// gets "yes" needs nothing more, and one who gets "no" needs the list, which
/// is one tap away and is the same list the booking step uses. A screen of its
/// own would be a second copy of that list to keep in step.
///
/// Counted from the cache rather than fetched: the numbers are the serviceable
/// set the destination step already loaded, so this cannot disagree with what
/// the customer sees when they open it.
class _ReachLine extends StatelessWidget {
const _ReachLine({required this.onTap});
/// Fixed, and the same number [_HomeScreenState._captionRoom] reserves.
///
/// A line whose height depended on its own padding and text metrics was a
/// number I had to estimate twice and got wrong both times — the fold
/// overflowed by eleven points, which is a precise amount of nothing to
/// debug. Declared once, consumed once.
static const double height = 40;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cities = AppScope.of(context).cachedCities;
if (cities == null || cities.isEmpty) return const SizedBox.shrink();
final states = <String>{for (final c in cities) c.state.code}.length;
final label =
'Delivering to ${cities.length} '
'${cities.length == 1 ? 'city' : 'cities'}'
'${states > 1 ? ' across $states states' : ''}';
return SizedBox(
height: height,
child: Center(
child: InkResponse(
onTap: onTap,
radius: 24,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
// ── The label gives way; the row does not push ──
//
// A `Row` of `min` size takes its intrinsic width and overflows
// whatever it is in when that is wider — and this sentence gets
// longer as Doormile opens more states, and longer again at a
// large text scale. It is one quiet line: it may ellipsize, but it
// must never be the thing that breaks the fold.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.mapPinned,
size: 13,
color: DmColors.ink4,
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
),
),
],
),
),
),
),
);
}
}
/// The live booking, at the head of Home.
///
/// ── Smaller, and without the courier ──
///
/// It carried a rule and a second row naming the Miler and their plate. Both
/// are real and neither belongs here: this card sits above the sphere now, so
/// every point it takes is a point the one action on the screen gives up — and
/// the Miler's name is on the screen this card opens, beside the button that
/// rings them, which is where it is any use.
///
/// What is left is what a glance asks: what is happening, where, and when.
/// Three lines and an arrow.
class _LiveCard extends StatelessWidget {
const _LiveCard({required this.booking});
final Booking booking;
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final slot = app.slotById(booking.slotId);
// The district alone. "Chennai, Tamil Nadu" shares its row with the state
// and the reference, and on a 402pt device it rendered "Chenna…" — the
// half of the name that identifies nothing.
final place = booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.shortLabel;
// The promise, in the order a customer can act on it: how long, then
// where they are, then what was agreed.
final promise = switch (booking.stage) {
JourneyStage.arrived => 'At your door now',
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
'Arriving in about ${booking.milerEtaMinutes} min',
JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay
when slot != null =>
'${slot.day} · ${slot.window}',
_ when booking.expectedDelivery != null =>
'Expected ${booking.expectedDelivery}',
_ => booking.stage.milestoneLabel,
};
return DmCard(
raised: true,
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.tracking,
(_) => const TrackingScreen(),
),
);
},
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 13, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const DmLiveDot(size: 6),
const SizedBox(width: 8),
Flexible(
child: Text(
booking.stage.chipLabel.toUpperCase(),
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 0.9,
color: DmColors.brand,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
booking.displayId,
style: DmText.monoSm.copyWith(
fontSize: 11,
color: DmColors.ink3,
),
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 9),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
place,
style: DmText.headerTitle.copyWith(fontSize: 21),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
promise,
style: DmText.small.copyWith(
fontSize: 13,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.brandSoft,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.arrowRight,
size: 17,
color: DmColors.brand,
),
),
],
),
],
),
),
],
);
}
}
/// Where Doormile is serving this customer, under their name.
///
/// ── It went missing, and the screen lost its footing ──
///
/// This pill has been most shapes — box, line, pill, row — and at one point it
/// was taken off Home entirely on the argument that the booking form below
/// already carried the pickup. The argument was wrong in a way that is only
/// obvious once it is gone: the form is four hundred points down the screen,
/// under a sphere, and a customer who has just got off a train wants to know
/// *before* they touch anything whether the app has followed them. A location
/// you can only see by scrolling to a form is a location you do not trust.
///
/// So it is back, directly under the greeting, where the first glance lands.
/// A brand pin, the locality, and a chevron-down — down rather than right,
/// because what it opens is a chooser and not another screen.
///
/// ── The state it never used to show ──
///
/// It printed whatever [AppState.pickup] held and nothing else, so while the
/// fix and the reverse-geocode were in flight it showed the *last* place the
/// customer was — confidently, with a pin beside it. Now the resolving case is
/// its own: a muted pin, a dimmed line, and the word for what is happening.
class _PickupLine extends StatelessWidget {
const _PickupLine({
required this.area,
required this.resolving,
required this.onTap,
});
/// The locality line, or null when nothing has been resolved yet.
final String? area;
/// A fix or a reverse-geocode is in flight.
final bool resolving;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final unset = area == null;
final label = resolving
? 'Finding your location\u2026'
: (area ?? 'Set a pickup address');
// Muted while it is a guess or a prompt; full brand once it is an answer.
final pinColour = resolving || unset ? DmColors.ink4 : DmColors.brand;
return Semantics(
button: true,
label: resolving
? 'Finding your pickup location'
: 'Picking up from $label. Change',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 40, maxWidth: 300),
padding: const EdgeInsets.fromLTRB(11, 8, 11, 8),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
border: Border.all(color: DmColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.mapPin, size: 15, color: pinColour),
const SizedBox(width: 8),
Flexible(
child: AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.cardTitle.copyWith(
fontSize: 14,
color: resolving || unset ? DmColors.ink3 : null,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 6),
const Icon(
LucideIcons.chevronDown,
size: 15,
color: DmColors.ink4,
),
],
),
),
),
),
);
}
}
/// Pickup over drop, on one card — the shape every ride app asks a journey in.
///
/// ── Why the app has two ways to start and only one of them is a sphere ──
///
/// BOOK NOW is the product: a state, a district, a window, and the Miler
/// writes down the door when they get there. It is a sphere because it is one
/// gesture, and a gesture cannot be a form.
///
/// This is the same booking for a customer who already knows the door. Both
/// ends are here because a journey has two, and seeing them together is what
/// makes it obvious that one is already answered — the pickup carries the
/// address the app detected, so the only row with a question on it is the
/// drop.
///
/// It replaced a line of text reading "Enter the full address instead", which
/// said what would happen and showed none of it. A form that is already half
/// filled in is a better argument for itself than a sentence about forms.
class _PickDropForm extends StatelessWidget {
const _PickDropForm({
required this.pickup,
required this.onPickup,
required this.onDrop,
});
final String? pickup;
final VoidCallback onPickup;
final VoidCallback onDrop;
@override
Widget build(BuildContext context) {
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 6, 14, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_End(
label: 'PICKUP',
value: pickup ?? 'Set a pickup address',
unset: pickup == null,
origin: true,
last: false,
onTap: onPickup,
),
_End(
label: 'DROP',
value: 'Where is it going?',
note: 'Enter the full address yourself',
unset: true,
origin: false,
last: true,
onTap: onDrop,
),
],
),
),
],
);
}
}
/// One end of the journey: a node on a thread, a micro-label, and the answer
/// or the question.
class _End extends StatelessWidget {
const _End({
required this.label,
required this.value,
required this.unset,
required this.origin,
required this.last,
required this.onTap,
this.note,
});
final String label;
final String value;
/// What choosing this row means. Only the drop carries one — the pickup is
/// already answered, and a caption under an answer is noise.
final String? note;
final bool unset;
final bool origin;
final bool last;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: '$label $value',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The node and the thread it hangs from. The thread belongs to
// the stop above the join, so the last one ends clean.
SizedBox(
width: 20,
child: Column(
children: [
const SizedBox(height: 17),
Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: unset && !origin
? Colors.transparent
: (origin ? DmColors.ink : DmColors.brand),
shape: BoxShape.circle,
border: unset && !origin
? Border.all(color: DmColors.brand, width: 2)
: null,
),
),
if (!last)
const Expanded(
child: VerticalDivider(
width: 1,
thickness: 1,
color: DmColors.border,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(0, 10, 0, last ? 12 : 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: DmText.eyebrow),
const SizedBox(height: 2),
Text(
value,
style: DmText.cardTitle.copyWith(
fontSize: 15.5,
color: unset ? DmColors.brand : null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 17,
color: DmColors.ink4,
),
],
),
),
),
);
}
}