import 'package:flutter/material.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. An outlined diamond — the parcel passes /// through, it does not stop here. hub, /// The destination. A filled crimson diamond, 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 — dashed outline. 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? 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), ), ), // Rotated squares rather than a second circle: on a thread of three // identical dots nothing tells you which end matters, and at 11pt a // diamond is distinguishable from a circle at a glance where two // shades of the same dot are not. DmNodeKind.hub => Transform.rotate( angle: 0.785398, child: Container( width: 10, height: 10, decoration: BoxDecoration( color: DmColors.surface, borderRadius: BorderRadius.circular(2), border: Border.all(color: DmColors.brand, width: 2), ), ), ), DmNodeKind.drop => Transform.rotate( angle: 0.785398, child: Container( width: 10, height: 10, decoration: BoxDecoration( color: DmColors.brand, borderRadius: BorderRadius.circular(2), ), ), ), DmNodeKind.pending => Transform.rotate( angle: 0.785398, child: Container( width: 10, height: 10, decoration: BoxDecoration( color: DmColors.surface, borderRadius: BorderRadius.circular(2), border: Border.all(color: DmColors.ink4, width: 1.5), ), ), ), }, ), ); } } /// 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; }