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>
244 lines
7.8 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|