Files
doormile_customer_app/lib/ui/widgets/route_rail.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

329 lines
9.4 KiB
Dart

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<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),
),
),
// 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;
}