Files
doormile_customer_app/lib/ui/widgets/route_rail.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

317 lines
9.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
/// One stop on a rail: where it goes, plus anything worth saying about it.
class DmRailStop {
const DmRailStop({required this.label, this.note, this.trailing});
final String label;
/// Second line under the label.
final String? note;
/// Right-aligned text, e.g. "2 packages".
final String? trailing;
}
/// What a node on the thread stands for.
enum DmNodeKind {
/// Where the parcel starts. A hollow circle: a door, not a decision.
origin,
/// A sorting hub in between. A hollow crimson ring — the parcel passes
/// through, it does not stop here.
hub,
/// The destination. A filled map pin, the only solid node on the thread, so
/// the eye lands on where the parcel is going.
drop,
/// A leg that has not been arranged yet — an outlined pin.
pending,
}
/// Pickup → drop, drawn as a vertical thread.
///
/// One component for a journey wherever it appears: an order card, the review
/// summary, tracking. It takes a single [drop] or, when a pickup serves several
/// places, a list of [drops] hanging off the same origin.
class DmRouteRail extends StatelessWidget {
const DmRouteRail({
super.key,
required this.pickup,
this.pickupNote,
this.pickupEyebrow,
this.pickupTrailing,
this.drop,
this.dropNote,
this.dropsEyebrow,
this.drops,
this.compact = false,
this.hub,
this.hubNote,
this.hubTrailing,
}) : assert(drop != null || drops != null, 'give the rail somewhere to go');
final String pickup;
final String? pickupNote;
/// Uppercase micro-label above the origin, e.g. "PICKUP".
final String? pickupEyebrow;
/// Action rendered at the right of the origin row.
final Widget? pickupTrailing;
/// The single-destination case.
final String? drop;
final String? dropNote;
/// Uppercase micro-label above the first destination.
final String? dropsEyebrow;
/// Several destinations hanging off one pickup.
final List<DmRailStop>? drops;
final bool compact;
/// The sorting hub between pickup and drop, when the customer has been told
/// about it. Omitted on a hyperlocal run, which never sees a hub.
final String? hub;
final String? hubNote;
final Widget? hubTrailing;
@override
Widget build(BuildContext context) {
final stops = drops ?? [DmRailStop(label: drop!, note: dropNote)];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Leg(
eyebrow: pickupEyebrow,
label: pickup,
note: compact ? null : pickupNote,
trailing: pickupTrailing,
kind: DmNodeKind.origin,
connector: true,
dashedConnector: hub != null,
compact: compact,
),
if (hub != null)
_Leg(
label: hub!,
note: compact ? null : hubNote,
trailing: hubTrailing,
kind: DmNodeKind.hub,
labelColor: DmColors.brand,
connector: true,
dashedConnector: true,
compact: compact,
),
for (var i = 0; i < stops.length; i++)
_Leg(
eyebrow: i == 0 ? dropsEyebrow : null,
label: stops[i].label,
note: compact ? null : stops[i].note,
trailingText: stops[i].trailing,
kind: DmNodeKind.drop,
connector: i != stops.length - 1,
compact: compact,
),
],
);
}
}
class _Leg extends StatelessWidget {
const _Leg({
required this.label,
required this.note,
required this.kind,
required this.connector,
required this.compact,
this.dashedConnector = false,
this.eyebrow,
this.trailing,
this.trailingText,
this.labelColor,
});
final String label;
final String? note;
final DmNodeKind kind;
final bool connector;
final bool dashedConnector;
final bool compact;
final String? eyebrow;
final Widget? trailing;
final String? trailingText;
final Color? labelColor;
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 15,
child: Column(
children: [
SizedBox(height: eyebrow == null ? 3 : 19),
_Node(kind: kind),
if (connector)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: CustomPaint(
painter: _Thread(dashed: dashedConnector),
size: const Size(1.5, double.infinity),
),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: connector ? (compact ? 13 : 16) : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
const SizedBox(height: 4),
],
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
label,
style: DmText.cardTitle.copyWith(color: labelColor),
),
),
if (trailingText != null) ...[
const SizedBox(width: 10),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
trailingText!,
textAlign: TextAlign.right,
style: DmText.small,
),
),
],
?trailing,
],
),
if (note != null) ...[
const SizedBox(height: 3),
Text(note!, style: DmText.small),
],
],
),
),
),
],
),
);
}
}
class _Node extends StatelessWidget {
const _Node({required this.kind});
final DmNodeKind kind;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 15,
height: 15,
child: Center(
child: switch (kind) {
DmNodeKind.origin => Container(
width: 11,
height: 11,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: DmColors.ink4, width: 2),
),
),
// ── The destination is a pin, not a diamond ──
//
// A rotated square distinguishes the ends of the thread, which was
// the whole argument for it, and it does that by being a shape with
// no meaning attached. A map pin is the mark every customer already
// reads as *a place*, so the thread stops needing a legend: hollow
// circle where you are, pin where it is going.
DmNodeKind.hub => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: DmColors.surface,
border: Border.all(color: DmColors.brand, width: 2),
),
),
DmNodeKind.drop => const Icon(
LucideIcons.mapPin,
size: 15,
color: DmColors.brand,
),
DmNodeKind.pending => const Icon(
LucideIcons.mapPin,
size: 15,
color: DmColors.ink4,
),
},
),
);
}
}
/// The line between two nodes. Solid where the parcel is handled, dashed
/// across a leg it merely travels — the distinction the design draws between
/// a doorstep and a trunk route.
class _Thread extends CustomPainter {
const _Thread({required this.dashed});
final bool dashed;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = DmColors.border
..strokeWidth = 1.5
..strokeCap = StrokeCap.round;
final x = size.width / 2;
if (!dashed) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
return;
}
const dash = 2.0;
const gap = 4.0;
var y = 0.0;
while (y < size.height) {
canvas.drawLine(
Offset(x, y),
Offset(x, (y + dash).clamp(0, size.height)),
paint,
);
y += dash + gap;
}
}
@override
bool shouldRepaint(covariant _Thread oldDelegate) =>
oldDelegate.dashed != dashed;
}