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

244 lines
7.8 KiB
Dart

import 'package:flutter/material.dart';
import '../../data/models.dart';
import '../format.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'cards.dart';
/// The customer's journey: seven milestones, not nine backend states.
///
/// Operational detail — "Miler on the way", "Order created" — is shown as
/// context beneath the milestone that is currently happening, so a transition
/// informs without becoming another permanent row.
///
/// ── Newest first, and only as many as matter ──
///
/// The list used to run oldest-to-newest, full height, every time: seven rows
/// of which four were grey placeholders for things that have not happened. On
/// a screen whose job is "where is my parcel right now", the answer was at the
/// bottom, below the scroll. It now leads with what just happened and, when
/// [maxRows] is set, keeps the rest behind one line — the same compression the
/// design applies to the live tracking card.
class DmMilestones extends StatelessWidget {
const DmMilestones({
super.key,
required this.booking,
this.stage,
this.title = 'Recent updates',
this.maxRows,
this.onExpand,
});
final Booking booking;
/// Journey to show. Defaults to the pickup's own stage; a destination order
/// passes its own.
final JourneyStage? stage;
final String title;
/// Show at most this many rows, newest first. Null shows the whole journey.
final int? maxRows;
/// Tapped from the "All n stages" action. Null hides the action.
final VoidCallback? onExpand;
@override
Widget build(BuildContext context) {
final current = stage ?? booking.stage;
final currentMilestone = CustomerMilestone.of(current);
final milestones = CustomerMilestone.values;
final currentIndex = currentMilestone.index;
// Newest first: the current milestone, then everything already done, then
// — only if there is room — what is still to come.
final ordered = [
for (var i = currentIndex; i >= 0; i--) i,
for (var i = currentIndex + 1; i < milestones.length; i++) i,
];
final shown = maxRows == null ? ordered : ordered.take(maxRows!).toList();
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title.toUpperCase(),
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
label: 'All ${milestones.length} stages',
onPressed: onExpand,
minHeight: 24,
)
else
Text(
'Step ${currentIndex + 1} of ${milestones.length}',
style: DmText.monoSm,
),
],
),
const SizedBox(height: 14),
for (var n = 0; n < shown.length; n++)
_MilestoneRow(
milestone: milestones[shown[n]],
done: shown[n] < currentIndex,
isCurrent: shown[n] == currentIndex,
last: n == shown.length - 1,
time: shown[n] <= currentIndex
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
: '',
detail: shown[n] == currentIndex ? _detailFor(current) : null,
),
],
),
),
],
);
}
/// One line of operational context for the stage actually in progress.
String? _detailFor(JourneyStage current) => switch (current) {
JourneyStage.booked => 'Matching the nearest available Miler',
JourneyStage.assigned =>
booking.miler == null
? null
: '${booking.miler!.name} accepted this pickup',
JourneyStage.onTheWay =>
booking.milerDistanceKm == null
? 'Miler on the way'
: 'Miler on the way · ${booking.milerDistanceKm} km away, '
'about ${booking.milerEtaMinutes} min',
JourneyStage.arrived => 'Miler arrived at your door · verification OTP ready',
JourneyStage.pickedUp => 'Weighing and sealing your packages',
JourneyStage.orderCreated =>
booking.isMultiDestination
? 'Order created for each destination · tracking issued'
: 'Order created · tracking number issued',
JourneyStage.inTransit => 'Moving through our network',
JourneyStage.outForDelivery => 'Out with the delivery Miler',
JourneyStage.delivered => null,
};
}
class _MilestoneRow extends StatelessWidget {
const _MilestoneRow({
required this.milestone,
required this.done,
required this.isCurrent,
required this.last,
required this.time,
this.detail,
});
final CustomerMilestone milestone;
final bool done;
final bool isCurrent;
final bool last;
final String time;
final String? detail;
@override
Widget build(BuildContext context) {
final upcoming = !done && !isCurrent;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 12,
child: Column(
children: [
const SizedBox(height: 3),
_Marker(done: done, isCurrent: isCurrent),
if (!last)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Container(width: 1.5, color: DmColors.border),
),
),
],
),
),
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
milestone.label,
style: upcoming
? DmText.small.copyWith(color: DmColors.ink3)
: DmText.cardTitle.copyWith(fontSize: 14.5),
),
),
if (time.isNotEmpty) ...[
const SizedBox(width: 10),
Text(time, style: DmText.monoSm),
],
],
),
if (detail != null) ...[
const SizedBox(height: 4),
Text(detail!, style: DmText.small),
],
],
),
),
),
],
),
);
}
}
class _Marker extends StatelessWidget {
const _Marker({required this.done, required this.isCurrent});
final bool done;
final bool isCurrent;
@override
Widget build(BuildContext context) {
if (isCurrent) {
return Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: DmColors.brandSoft, spreadRadius: 4),
],
),
);
}
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: done ? DmColors.brand : DmColors.border,
shape: BoxShape.circle,
),
);
}
}