production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -107,13 +107,19 @@ class DutySheet extends StatelessWidget {
// One sentence. The off-duty version carries the fact that used
// to be discovered only after a failed slide.
//
// Both strings were blanked at some point and the sheet shipped as a
// question with two buttons and no consequence — which is the one
// thing this rewrite existed to keep. `duty_sheet_test` names all
// three facts it must contain, so they are restored to what that
// test has always asked for.
Text(
goingOnline
? 'Your hub can add you to the next slot, and live '
'tracking runs while you are on.'
: 'Your hub stops assigning you trips and live tracking '
'pauses. Any booking you are holding has to be '
'finished first.',
? 'Your office can start assigning you trips, and live tracking '
'runs while you are on duty.'
: 'Your office stops assigning you trips and live tracking pauses. '
'A stop you have already started has to be finished '
'first.',
style: TextStyle(
fontSize: 14.5.sp,
height: 1.45,

View File

@@ -0,0 +1,616 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE HOME HEADER — identity, duty, and the trip being worked
///
/// ── What was wrong with the last one ──
///
/// It was a fixed block above the scroll view — the Column's first child — and
/// it never went away. Everything it carried was therefore charged at every
/// scroll position, and it carried a lot: a 44pt avatar, an eyebrow reading
/// DOORMILE DELIVERY, "Hi, Rajan", a 34pt two-word duty switch, and the trip
/// selector inside a translucent rounded track of its own. Five objects
/// competing, none of them the work.
///
/// Two of them were also answering questions the app answers elsewhere. The
/// avatar duplicated the Account tab in the bottom bar. The trip's `33 of 50
/// done` was printed again, larger, in the run card immediately below it.
///
/// The cost was the only thing that mattered: on a 390×844 phone the rider's
/// first actual stop began below the fold.
///
/// ── What this is ──
///
/// One pinned sliver with two states and a short cross-fade between them.
///
/// ```
/// EXPANDED (~166dp of content, plus the status bar)
/// ┌──────────────────────────────────────────────────┐
/// │ DOORMILE ● ON │
/// │ │
/// │ Good afternoon, Rajan │
/// │ 17 stops remaining │
/// │ │
/// │ TRIP 1 TRIP 2 TRIP 3 │
/// │ 33 / 50 Not set Not set │
/// │ ━━━━━━━ │
/// └──────────────────────────────────────────────────┘
///
/// COLLAPSED (52dp of content, plus the status bar)
/// ┌──────────────────────────────────────────────────┐
/// │ Trip 1 · 17 left · 33/50 ● ON │
/// └──────────────────────────────────────────────────┘
/// ```
///
/// The greeting and the wordmark are the decoration, so they are what goes.
/// What survives the collapse is what a rider glancing at a mounted phone
/// needs: which trip, how much is left, and whether he is on duty.
///
/// ── Why a `SliverPersistentHeader` and not a `SliverAppBar` ──
///
/// `SliverAppBar` gives a `FlexibleSpaceBar`, which interpolates *one* piece of
/// content between two positions and scales it. This header does not scale
/// anything: it has two different layouts and swaps one for the other. A
/// persistent-header delegate hands over `shrinkOffset` directly, which is
/// exactly the input a cross-fade needs, and it is native — no package was
/// added for any of this.
/// ─────────────────────────────────────────────────────────────────────────
class HomeHeaderDelegate extends SliverPersistentHeaderDelegate {
/// The status-bar inset. Passed in rather than read from a `MediaQuery`,
/// because [minExtent] and [maxExtent] are called without a context.
final double topInset;
/// `Good afternoon, Rajan` — composed by the page, which owns the clock.
final String greeting;
/// `17 stops remaining`, or empty when there is no run to count.
///
/// **Not drawn in the expanded state** — see [_ExpandedBody]. It survives
/// because the page folds it into [collapsedSummary] for the collapsed line,
/// where the brief card is no longer on screen to carry the figure.
final String subtitle;
final bool onDuty;
/// Duty cannot be changed mid-pickup. The control dims and locks rather than
/// disappearing, so the state stays readable.
final bool dutyLocked;
/// A duty write is in flight. The control shows it and refuses a second
/// press; the page's own confirmation flow is untouched.
final bool dutyBusy;
final VoidCallback onToggleDuty;
/// The collapsed line's operational summary — `Trip 1 · 17 left · 33/50`.
/// Composed by the page from the same figures the selector draws, so the two
/// states cannot disagree.
final String collapsedSummary;
/// The expanded content's height for this context's text scale — see
/// [expandedContentFor]. Passed in for the same reason as [topInset]: the
/// extent getters have no context to measure with.
final double expandedContent;
const HomeHeaderDelegate({
required this.topInset,
required this.expandedContent,
required this.greeting,
required this.subtitle,
required this.onDuty,
required this.dutyLocked,
required this.dutyBusy,
required this.onToggleDuty,
required this.collapsedSummary,
});
/// The collapsed row's own height, without the status bar.
static double get collapsedContent => 52.h;
/// The header's own surface. Keyed so a test can measure *this* rather than
/// the `Scaffold`'s Material, which is the whole screen.
static const Key surfaceKey = ValueKey('home_header_surface');
/// The expanded content's height, without the status bar.
///
/// ── Why this is measured rather than hardcoded ──
///
/// A sliver header must declare a fixed extent, so anything short of the
/// content is a silent clip. The greeting is type that grows with the system
/// font, so the extent has to grow with it or a rider at 1.5× loses the
/// bottom of the header.
///
/// The `N stops remaining` line was charged here too until it was removed as
/// a duplicate of the brief card's own headline — which is why this is 20-odd
/// points cheaper than it was, and why the budget test moved with it.
///
/// Scaling is clamped at 1.3× inside the header (see [build]) for the same
/// reason the run card clamps it: these are figures and labels read at a
/// glance, not prose, and an unclamped 2.0× turns a 166pt header into a
/// 240pt one that pushes the work off the screen entirely — which is the
/// problem this redesign exists to fix.
static double expandedContentFor(BuildContext context) {
final scale = MediaQuery.textScalerOf(context).scale(1).clamp(1.0, 1.3);
// ── Ceiled, because text does not lay out in fractions of a pixel ──
//
// `fontSize * height` is the line box in theory; the engine rounds font
// metrics, so the greeting measures up to a pixel taller than the
// arithmetic says. That slack used to be absorbed by the 4pt gap above the
// `stops remaining` line, and removing that line exposed it — a header
// short by 0.12 to 0.4 of a pixel, which is a `RenderFlex overflowed`
// stripe across Home for a rounding error.
//
// Ceiling the term is the fix rather than adding a fudge constant: it is
// exactly the quantity being rounded, and it cannot drift the way a magic
// `+2` bolted on the end would.
return topPad +
wordmarkRow +
2.h +
(24.sp * 1.15 * scale).ceilToDouble() + // greeting
bottomPad;
}
/// The three metrics [_ExpandedBody] lays out against, named here so the
/// measurement above and the layout below cannot drift apart — a sliver
/// header whose extent disagrees with its content is a silent clip.
static double get topPad => 6.h;
/// The wordmark and duty row.
///
/// [DutyPill.tapTarget], not the pill's own ~30pt: the row holds the one
/// control up here, and a 48pt target inside a 28pt row is a 28pt target.
/// The eight points this costs came off the paddings either side of it.
static double get wordmarkRow => DutyPill.tapTarget;
static double get bottomPad => 20.h;
/// Everything the header draws, so the page can measure the fold.
static double heightFor(BuildContext context) =>
MediaQuery.paddingOf(context).top + expandedContentFor(context);
@override
double get minExtent => topInset + collapsedContent;
@override
double get maxExtent => topInset + expandedContent;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlaps) {
final range = (maxExtent - minExtent).clamp(1.0, double.infinity);
// 0 fully expanded → 1 fully collapsed.
final t = (shrinkOffset / range).clamp(0.0, 1.0);
// ── Two fades that do not overlap in the middle ──
//
// Crossing them linearly leaves both layouts half-visible for most of the
// travel, which reads as the header being broken rather than as it
// changing. The expanded content is gone by 60% of the journey and the
// collapsed line does not start until 55%, so there is a beat where the bar
// is just brand — and that beat is what makes the swap read as deliberate.
final expandedOpacity = (1 - t / 0.6).clamp(0.0, 1.0);
final collapsedOpacity = ((t - 0.55) / 0.45).clamp(0.0, 1.0);
return Material(
key: surfaceKey,
// ── A gradient, and it is nearly not one ──
//
// Two stops about eight per cent apart, top-left to bottom-right. Enough
// that the header reads as a surface with light falling across it rather
// than as a rectangle filled with one hex value; not enough to be seen as
// a gradient, which on a brand ground is the difference between premium
// and 2014. The brand red stays the darker stop, so nothing about the
// colour the app is known by changes.
type: MaterialType.transparency,
// ── The corner, and the case for evaluating it ──
//
// A 20pt bottom radius reads well at rest: the header is an object
// sitting on the page rather than a band painted across the top of it.
// Pinned over scrolling content it reads as a mistake — a rounded lid
// with rows sliding under its corners.
//
// So it is a rounded card when it is at rest and a straight bar when it
// is holding the top of the screen, interpolated on the same `t` as
// everything else. Straight was the alternative considered and it is
// cleaner while scrolling and flatter at rest; this keeps both.
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(20.r * (1 - t)),
),
clipBehavior: Clip.antiAlias,
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA51C1C), Color(0xFF8B1A1A)],
),
),
child: MediaQuery.withClampedTextScaling(
maxScaleFactor: 1.3,
child: Padding(
padding: EdgeInsets.only(top: topInset),
child: Stack(
fit: StackFit.expand,
children: [
if (collapsedOpacity > 0)
Align(
alignment: Alignment.topCenter,
child: Opacity(
opacity: collapsedOpacity,
child: SizedBox(
height: collapsedContent,
child: _CollapsedRow(
summary: collapsedSummary,
onDuty: onDuty,
locked: dutyLocked,
busy: dutyBusy,
onTap: onToggleDuty,
),
),
),
),
if (expandedOpacity > 0)
// ── Laid out at its full height, then clipped ──
//
// This was a plain child of a `StackFit.expand` stack, so it
// was given the header's *current* height — and the header's
// current height is shrinking. The greeting and its subtitle
// are a Column of fixed rows that cannot compress, so from
// the first pixel of scroll the Column was handed less than
// it needs and Flutter drew the yellow-and-black
// `BOTTOM OVERFLOWED BY 28 PIXELS` banner across the header.
//
// The fade already handles the disappearing. What this
// content must do while it fades is *hold its shape and slide
// out from under the top of the screen*, which is what a
// collapsing header looks like — not reflow itself into a
// smaller and smaller box.
//
// `Positioned` with an explicit height opts out of
// `StackFit.expand`: the body is measured at
// [expandedContent] whatever the header currently is, pinned
// to the top, and the overflow is clipped by the Stack (and
// rounded off by the Material above it) instead of being
// reported as a layout error.
Positioned(
top: 0,
left: 0,
right: 0,
height: expandedContent,
// Ignored once it is mostly faded, so a half-visible
// greeting cannot swallow a tap meant for the collapsed
// control under it.
child: IgnorePointer(
ignoring: expandedOpacity < 0.5,
child: Opacity(
opacity: expandedOpacity,
child: _ExpandedBody(
greeting: greeting,
onDuty: onDuty,
dutyLocked: dutyLocked,
dutyBusy: dutyBusy,
onToggleDuty: onToggleDuty,
),
),
),
),
],
),
),
),
),
);
}
/// ── The rebuild gate ──
///
/// The whole point of listing every field: a row further down the trip
/// changing does not touch any of these, so the header does not rebuild for
/// it. The header carries no collections at all now that the trip selector
/// has moved out from under it, so every comparison here is a scalar.
@override
bool shouldRebuild(HomeHeaderDelegate old) =>
old.topInset != topInset ||
old.expandedContent != expandedContent ||
old.greeting != greeting ||
old.subtitle != subtitle ||
old.onDuty != onDuty ||
old.dutyLocked != dutyLocked ||
old.dutyBusy != dutyBusy ||
old.collapsedSummary != collapsedSummary;
}
/// The expanded layout: who, whether he is working, and which trip.
class _ExpandedBody extends StatelessWidget {
final String greeting;
final bool onDuty;
final bool dutyLocked;
final bool dutyBusy;
final VoidCallback onToggleDuty;
const _ExpandedBody({
required this.greeting,
required this.onDuty,
required this.dutyLocked,
required this.dutyBusy,
required this.onToggleDuty,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
20.w,
HomeHeaderDelegate.topPad,
16.w,
HomeHeaderDelegate.bottomPad,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: HomeHeaderDelegate.wordmarkRow,
child: Row(
children: [
// ── The mark, at label size ──
//
// It was `DOORMILE DELIVERY` under a 44pt avatar. The rider
// knows which app he opened; what the wordmark is for is
// telling him whose *shift* this is on a phone that may carry
// two of these. One word, set as a label, is enough for that.
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'DOORMILE',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.6,
color: Colors.white.withValues(alpha: 0.72),
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(width: 12.w),
DutyPill(
onDuty: onDuty,
locked: dutyLocked,
busy: dutyBusy,
onTap: onToggleDuty,
),
],
),
),
// Two, not ten. The row above is 48 tall to hold a 48pt tap target
// around a 30pt pill, so it already carries ~17pt of air under the
// wordmark — and a gap stacked on air is ten points of the rider's
// first stop.
SizedBox(height: 2.h),
Text(
greeting,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 24.sp,
height: 1.15,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
// ── The `N stops remaining` line used to sit here ──
//
// It was justified as "the one operational line above the fold". It
// stopped being the only one: the brief card immediately below leads
// with `20 stops left` as its headline, at nearly twice the size and
// against a progress bar. So the figure was on screen twice, forty
// points apart, the smaller and greyer of the two first.
//
// The header's job is identity and duty. The count belongs to the
// card that also shows what it is made of — see [TripBriefStrip].
//
// `subtitle` is kept on the delegate because the page still folds it
// into [collapsedSummary], which is the *only* place that figure
// appears once the header shrinks and the brief has scrolled away.
],
),
);
}
}
/// The collapsed layout: the operational context, and nothing else.
class _CollapsedRow extends StatelessWidget {
final String summary;
final bool onDuty;
final bool locked;
final bool busy;
final VoidCallback onTap;
const _CollapsedRow({
required this.summary,
required this.onDuty,
required this.locked,
required this.busy,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Row(
children: [
Expanded(
// Scale-down rather than ellipsis. This line is `Trip 1 · 17 left ·
// 33/50` — three figures — and an ellipsis takes the last one off,
// which is the one a rider is scrolling past the header to check.
// On a 320pt phone it shrinks instead.
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
summary,
maxLines: 1,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
SizedBox(width: 12.w),
DutyPill(onDuty: onDuty, locked: locked, busy: busy, onTap: onTap),
],
),
);
}
}
/// ── Duty, as a status rather than as a setting ──
///
/// It was `AnimatedToggleSwitch.dual`: a 34pt two-position track carrying the
/// words *On duty* and *Off duty* and a sliding knob. That is a settings
/// control — the shape a preferences screen uses — and it was the second
/// loudest object in a header whose subject is the work.
///
/// What a rider needs from it is an answer, not an affordance: **am I on
/// duty?** So it states the answer, in the smallest form that can carry it, and
/// the tap is how you change it. `● ON` reads at arm's length; the dot is
/// green, which is this palette's only positive-state colour and is spent
/// nowhere else in the header.
///
/// **Off is not red.** Off duty is not an error, and red on maroon is
/// invisible anyway — it is the same white pill with the dot unlit.
///
/// The control is visibly ~30pt tall and its target is 48. Nothing about the
/// duty *logic* changed: this calls the page's existing confirm-then-write
/// flow, and refuses a press while one is in flight or while a pickup is live.
class DutyPill extends StatelessWidget {
final bool onDuty;
final bool locked;
final bool busy;
final VoidCallback onTap;
const DutyPill({
super.key,
required this.onDuty,
required this.locked,
required this.busy,
required this.onTap,
});
/// The accessibility floor, which the visible pill is deliberately under.
static const double tapTarget = 48.0;
@override
Widget build(BuildContext context) {
final enabled = !locked && !busy;
return Semantics(
button: true,
enabled: enabled,
toggled: onDuty,
label: locked
? 'On duty, locked while a pickup is running'
: busy
? 'Changing duty status'
: onDuty
? 'On duty. Tap to go off duty.'
: 'Off duty. Tap to go on duty.',
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled ? onTap : null,
// The target is the box; the pill inside it is what is drawn. A 30pt
// control with a 30pt target is a control a moving thumb misses.
child: SizedBox(
height: tapTarget,
child: Center(
child: AnimatedOpacity(
duration: DesignConstants.motionState,
opacity: enabled ? 1 : 0.55,
child: Container(
height: 30.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: onDuty ? 0.18 : 0.11),
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
border: Border.all(
color: Colors.white.withValues(alpha: onDuty ? 0.5 : 0.28),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (busy)
SizedBox(
width: 10.w,
height: 10.w,
child: const CircularProgressIndicator(
strokeWidth: 1.8,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
else if (locked)
Icon(LucideIcons.lock, size: 11.sp, color: Colors.white)
else
AnimatedContainer(
duration: DesignConstants.motionState,
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: onDuty
? ColorConstants.acceptGreen
: Colors.white.withValues(alpha: 0.45),
boxShadow: onDuty
? [
BoxShadow(
color: ColorConstants.acceptGreen
.withValues(alpha: 0.55),
blurRadius: 6,
),
]
: null,
),
),
SizedBox(width: 7.w),
Text(
onDuty ? 'ON' : 'OFF',
maxLines: 1,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/miler_map.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
/// ─────────────────────────────────────────────────────────────────────────
@@ -133,6 +134,34 @@ class PickupPreviewSheet extends StatelessWidget {
return (lat == 0 || lng == 0) ? null : (lat: lat, lng: lng);
}
/// The street address of the place, from the same order the pin comes from.
///
/// ── Why this is derived and not a constructor argument ──
///
/// The sheet drew the place's *name* and the word `Pickup location` under
/// it, and that was all: a rider who did not recognise the name had a map he
/// could not read a street off and a caption telling him what he already
/// knew from the icon. The address was in the payload the whole time.
///
/// Read off `lines.first.stop`, exactly as [_place] reads the coordinates —
/// every order here shares one counter, so they share one address, and
/// taking both from the same row means the pin and the words can never
/// describe different places.
String get _address {
if (lines.isEmpty) return '';
final stop = lines.first.stop;
for (final key in const [
'pickupaddress',
'PickupAddress',
'pickup_address',
'address',
]) {
final raw = (stop[key] ?? '').toString().trim();
if (raw.isNotEmpty && raw.toLowerCase() != 'null') return raw;
}
return '';
}
static double _d(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
@@ -146,20 +175,22 @@ class PickupPreviewSheet extends StatelessWidget {
// The kit owns fabric, handle and insets; this sheet owns only content.
// It was one of the two opaque holdouts — see the sheet kit's audit.
return MilerSheetScaffold(
// ── The same shape as the stop sheet ──
//
// These two are the same object at two moments — *this is the place, and
// this is what is there* — and they were laid out as two different
// screens: this one opened with a map and hung a title under it, while
// the stop sheet identified itself first and drew the map as evidence.
// A rider moving between them had to re-find everything.
//
// Identity, then the leg, then what is at it, then one action.
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (place != null) ...[_map(place), SizedBox(height: 16.h)],
Text(
placeName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.pageTitle,
),
SizedBox(height: 8.h),
_journey(),
SizedBox(height: 18.h),
_header(),
SizedBox(height: 14.h),
if (place != null) ...[_map(place), SizedBox(height: 18.h)],
_manifest(),
SizedBox(height: 18.h),
_actions(),
@@ -168,6 +199,74 @@ class PickupPreviewSheet extends StatelessWidget {
);
}
/// Who this place is, and what kind of place it is.
///
/// The tonal square and the brand tint are the stop sheet's, so the two read
/// as one family; the caption is what this sheet is *for* — a counter you are
/// about to ride to, with a load waiting on it.
Widget _header() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 42.w,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Narrative.burgundyWash,
borderRadius: BorderRadius.circular(Narrative.radiusChip),
),
child: Icon(
StopKind.pickup.icon,
size: 20.sp,
color: Narrative.burgundy,
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
placeName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.body.copyWith(
fontSize: 19.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.15,
color: Narrative.ink,
),
),
SizedBox(height: 2.h),
// ── The address, where there is one ──
//
// `Pickup location` was a label restating the icon beside it. It
// stays only as the fallback for a payload with no address,
// because a name with nothing under it reads as a missing line.
//
// Three lines, not one: an Indian street address is routinely
// `door, street, area, city` and clipping it at the first comma
// leaves the rider the half he already had from the heading.
Text(
_address.isEmpty ? 'Pickup location' : _address,
maxLines: _address.isEmpty ? 1 : 3,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 13.sp,
height: 1.35,
color: Narrative.inkMuted,
),
),
],
),
),
],
);
}
/// The leg, drawn once. Him, the counter, and the line between them.
///
/// Deliberately not interactive and deliberately not a navigation surface:
@@ -179,10 +278,17 @@ class PickupPreviewSheet extends StatelessWidget {
riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0;
final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null;
return ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
final distance = (meters != null && meters! > 0)
? RouteMetricsHelper.formatDistance(meters)
: null;
final time = ride > Duration.zero
? '~${RouteMetricsHelper.formatDuration(ride)}'
: null;
final frame = ClipRRect(
borderRadius: BorderRadius.circular(Narrative.radiusInner),
child: SizedBox(
height: 190.h,
height: 175.h,
// Held back until the sheet has finished rising: a platform map view
// costs the UI thread a couple of hundred milliseconds to build, and in
// the same frame as the entrance it swallows the animation whole. See
@@ -202,59 +308,66 @@ class PickupPreviewSheet extends StatelessWidget {
),
),
);
}
/// `➤ 3.1 km ◷ ~9 min` — the same pair, the same glyphs and the same rule
/// as the card this opened from. Absent halves are absent, never guessed.
Widget _journey() {
final distance = (meters != null && meters! > 0)
? RouteMetricsHelper.formatDistance(meters)
: null;
final time = ride > Duration.zero
? '~${RouteMetricsHelper.formatDuration(ride)}'
: null;
if (distance == null && time == null) return const SizedBox.shrink();
if (distance == null && time == null) return frame;
// Flexible, because rigid text in a Row is a right-edge overflow waiting
// for a large text scale — at 2.0x this pair ran 72px past the sheet.
return Row(
// ── The leg is written on the picture of the leg ──
//
// It was a line of figures under the map, which is a caption for a thing
// that can carry its own. Same strip, same words and the same scrim as the
// stop sheet, so the two surfaces state the one fact identically.
return Stack(
children: [
if (distance != null) ...[
Icon(
LucideIcons.navigation,
size: 16.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
distance,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.figure(17, color: ColorConstants.slateText),
frame,
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ClipRRect(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(Narrative.radiusInner),
),
),
],
if (distance != null && time != null) SizedBox(width: 18.w),
if (time != null) ...[
Icon(
LucideIcons.clock,
size: 16.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
time,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.label.copyWith(
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
color: Narrative.card.withValues(alpha: 0.9),
child: Row(
children: [
Icon(
LucideIcons.navigation,
size: 13.sp,
color: Narrative.ink,
),
SizedBox(width: 6.w),
if (distance != null)
Flexible(
child: Text(
'$distance away',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.body.copyWith(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: Narrative.ink,
),
),
),
if (distance != null && time != null)
Flexible(
child: Text(
' · $time ride',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 12.sp,
color: Narrative.inkMuted,
),
),
),
],
),
),
),
],
),
],
);
}
@@ -271,78 +384,103 @@ class PickupPreviewSheet extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
BagManifest.countLabel(lines.length),
style: MilerType.body.copyWith(
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
'WHAT TO COLLECT',
style: MilerType.eyebrow.copyWith(
fontSize: 9.5.sp,
letterSpacing: 0.8,
color: Narrative.inkMuted,
),
),
SizedBox(height: 10.h),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: 190.h),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final line in lines)
Padding(
padding: EdgeInsets.symmetric(vertical: 7.h),
child: Row(
children: [
Container(
width: 7.w,
height: 7.w,
decoration: BoxDecoration(
color: ColorConstants.borderStrong,
shape: BoxShape.circle,
),
),
SizedBox(width: 12.w),
Expanded(
child: Text(
line.customer.isEmpty ? 'Order' : line.customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.body.copyWith(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurfaceVariant,
SizedBox(height: 8.h),
// ── The manifest is a block, not a loose list ──
//
// It was rows on the sheet's own white, which left the bag column and
// the names floating with nothing holding them, and the count sitting
// above as a heading for air. On a tonal block — the stop sheet's own
// idiom for "here is what is at this place" — it is one object, and the
// count belongs to it.
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
decoration: BoxDecoration(
color: Narrative.inset,
borderRadius: BorderRadius.circular(Narrative.radiusInner),
),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 190.h),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final line in lines)
Padding(
padding: EdgeInsets.symmetric(vertical: 7.h),
child: Row(
children: [
Container(
width: 7.w,
height: 7.w,
decoration: BoxDecoration(
color: ColorConstants.borderStrong,
shape: BoxShape.circle,
),
),
),
SizedBox(width: 10.w),
// Ranked under the name, exactly as on the route
// timeline: w700 slate against a w500 mid-tone name
// made a column of shelf references the first thing
// read on a manifest of people.
Container(
padding: EdgeInsets.symmetric(
horizontal: 7.w,
vertical: 3.h,
),
decoration: BoxDecoration(
color: MilerSurface.canvas,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
SizedBox(width: 12.w),
Expanded(
child: Text(
line.customer.isEmpty ? 'Order' : line.customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.body.copyWith(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurfaceVariant,
),
),
),
child: Text(
line.bag,
maxLines: 1,
style: MilerType.label.copyWith(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
SizedBox(width: 10.w),
// Ranked under the name, exactly as on the route
// timeline: w700 slate against a w500 mid-tone name
// made a column of shelf references the first thing
// read on a manifest of people.
Container(
padding: EdgeInsets.symmetric(
horizontal: 7.w,
vertical: 3.h,
),
decoration: BoxDecoration(
color: MilerSurface.canvas,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
line.bag,
maxLines: 1,
style: MilerType.label.copyWith(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
),
),
],
],
),
),
),
],
],
),
),
),
),
SizedBox(height: 8.h),
Text(
BagManifest.countLabel(lines.length),
style: MilerType.micro.copyWith(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
color: Narrative.inkMuted,
),
),
],
);
}

View File

@@ -121,6 +121,14 @@ class RouteGroup {
/// The rung every choosable order in this group is on.
final StopState state;
/// Each of [allStops]' own state, in the same order.
///
/// The visible half is [stopStates]; this is the whole bucket, and it is what
/// lets [groupSize] tell an order that is still this counter's business from
/// one that has finished and left it. Empty when a caller does not track it,
/// in which case [groupSize] falls back to the raw count.
final List<StopState> allStopStates;
/// Each of [stops]' own state, in the same order.
///
/// A group's rung is the rung it is *worked* on, and a group can hold orders
@@ -150,7 +158,42 @@ class RouteGroup {
/// number again.
int get onRungCount => stopStates.where((s) => s == state).length;
int get groupSize => allStops.isNotEmpty ? allStops.length : stops.length;
/// The denominator of `Accepted 1/4` — **this counter's load**.
///
/// ── Why it is not `allStops.length` ──
///
/// It was, and the reasoning was sound as far as it went: an order the rider
/// has collected leaves the visible list but did not leave the counter, so
/// counting only what is on screen would renumber the place under him.
///
/// What that missed is that [allStops] is every order the trip ever bucketed
/// under this source — including the ones already **delivered**, which are
/// Activity's business and not this card's, and the ones he **declined**,
/// which he has finished with. On a full day off one kitchen that is most of
/// them: a rider with ten orders in the dropdown and one accepted read
/// `Accepted 1/63`, where 63 was the whole day and 10 was the question in
/// front of him. A fraction whose denominator the rider cannot see anywhere
/// on the screen is worse than no fraction.
///
/// So the load is stated the way he would state it: **what is still in the
/// dropdown, plus what he has already taken off this counter**. Collected
/// orders keep their place in the arithmetic, which was the original point;
/// finished and declined ones drop out of it, which was the bug.
///
/// Skipped orders are out too. One is still owed at the counter, but it is
/// parked for a return visit and it is not part of the accept decision this
/// chip is a fraction of — see [onRungCount], which counts the accepted rung.
int get groupSize {
// A caller that does not supply the states gets the old arithmetic rather
// than a wrong number: better to over-count than to invent a denominator.
if (allStopStates.length != allStops.length) {
return allStops.isNotEmpty ? allStops.length : stops.length;
}
final carried = allStopStates
.where((s) => s == StopState.collected)
.length;
return stops.length + carried;
}
/// True when the header **is** the stop: one order, at one address, with no
/// place above it worth naming.
@@ -169,6 +212,7 @@ class RouteGroup {
required this.firstIndex,
required this.state,
required this.stopStates,
this.allStopStates = const [],
this.travel = Duration.zero,
this.meters,
this.flat = false,
@@ -245,7 +289,7 @@ class RouteGroup {
bool get isDone => state == StopState.collected || state == StopState.done;
/// The rider is on this stop right now.
bool get isLive => state == StopState.active;
bool get isLive => state.isAtSource;
}
/// The whole run: a head, then one expandable group per pickup place.
@@ -270,10 +314,24 @@ class RouteTimeline extends StatefulWidget {
/// Toggles one order's tick.
final ValueChanged<String>? onToggleSelect;
/// Opens one order's detail sheet. Progressive disclosure for the address,
/// the phone and everything else a step deliberately does not carry.
/// Opens a **place's** detail sheet — a flat group's header, where one stop
/// is one location. Progressive disclosure for the address, the phone and
/// everything else a step deliberately does not carry.
final ValueChanged<Map<String, dynamic>>? onStopTap;
/// Opens **one order's** detail sheet, from a row inside an expanded place.
///
/// Separate from [onStopTap] because they are about different subjects, and
/// the sheet has to know which. Every order under a kitchen shares that
/// kitchen's address, so a sheet opened from an order row that pins the
/// kitchen draws the same map once per order and answers nothing about the
/// one the rider tapped — what distinguishes it is the door it is going to.
/// The place's own map is on the card's preview, beside the call.
///
/// Falls back to [onStopTap] when unset, so a caller that does not care keeps
/// the old single-callback behaviour.
final ValueChanged<Map<String, dynamic>>? onOrderTap;
/// Opens navigation for a whole group. **One per group, never per order** —
/// five Navigate buttons that go to the same counter was the single largest
/// source of clutter on the old screen.
@@ -311,6 +369,7 @@ class RouteTimeline extends StatefulWidget {
this.onSelectAll,
this.onToggleSelect,
this.onStopTap,
this.onOrderTap,
this.onNavigateGroup,
this.onCallGroup,
this.onPreviewGroup,
@@ -351,6 +410,7 @@ class _RouteTimelineState extends State<RouteTimeline> {
/// would put him back on a screen he has to scroll.
String? get _defaultOpenKey {
for (final rung in const [
StopState.arrived,
StopState.active,
StopState.accepted,
StopState.pending,
@@ -402,6 +462,7 @@ class _RouteTimelineState extends State<RouteTimeline> {
onSelectAll: widget.onSelectAll,
onToggleSelect: widget.onToggleSelect,
onStopTap: widget.onStopTap,
onOrderTap: widget.onOrderTap ?? widget.onStopTap,
onNavigate: widget.onNavigateGroup,
onCall: widget.onCallGroup,
onPreview: widget.onPreviewGroup,
@@ -641,7 +702,7 @@ class SelectAllAction extends StatelessWidget {
// [PickupTimelineGroup._railWidth] — so matching the number instead
// of the axis left a 2pt drift. The axis is the thing being matched;
// `home_grid_test.dart` measures it so the two cannot part again.
padding: EdgeInsets.only(left: scoped ? 10.w : 0),
padding: EdgeInsets.only(right: scoped ? 2.w : 0),
child: Row(
// ── Inside a panel it closes the row; at the run head it opens it ──
//
@@ -653,20 +714,21 @@ class SelectAllAction extends StatelessWidget {
//
// Unscoped — the run head — it is the first thing on the screen
// above the route and keeps the reading edge.
// ── Both versions read from the left ──
// ── The scoped one closes the row; the run head opens it ──
//
// The scoped one was pushed to the right edge, on the argument
// that a control with nothing beside it should sit on an edge and
// that a list's own controls live on the right. That was an
// argument about a *panel* — it had a right edge to sit on, and
// the orders were inside it.
// Inside a group the select-all is a *control over a list*, and a
// list's own controls sit on the right — the same edge the bag
// labels and the chevrons below it already use. On the left it
// headed the list like a label, which is what made it read as a
// stray tick above the first name rather than as the thing that
// ticks all five.
//
// On the open ground there is no panel, and the right edge now
// belongs to the bag column. A select-all floating out there reads
// as a fourth thing in the Bag axis. It heads the list it selects,
// so it starts where the list starts: the same left axis the
// customer names below it keep.
mainAxisAlignment: MainAxisAlignment.start,
// The run-head version has no list beside it and no right edge to
// close: it is the first thing above the route, so it keeps the
// reading edge.
mainAxisAlignment: scoped
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
// ── One rail, not two ──
//
@@ -680,16 +742,25 @@ class SelectAllAction extends StatelessWidget {
// the same arrangement every other railed row on this screen
// uses. `home_grid_test.dart` measures the axis so the pair
// cannot drift apart again.
// The mark is drawn by the rail on a scoped row — see
// [PickupTimelineGroup._selectAllMark] — so only the run-head
// version, which has no rail, carries it inline.
if (!scoped) ...[_glyph(all, some, accent), SizedBox(width: 8.w)],
// ── The mark goes where the words went ──
//
// The scoped row's tick was drawn by the rail, in the node
// column on the far left, while the label it belongs to sits on
// the right edge. A control split across the full width of a
// card is two controls as far as the eye is concerned: the tick
// read as a mark on the spine and the words as a caption, and
// nothing said they were one thing you press.
//
// Both versions carry it inline now, immediately before the
// words, and the rail draws nothing for this row.
_glyph(all, some, accent),
SizedBox(width: 8.w),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
textAlign: scoped ? TextAlign.right : TextAlign.left,
style: MilerType.label.copyWith(
fontWeight: FontWeight.w700,
color: accent,
@@ -733,6 +804,9 @@ class PickupTimelineGroup extends StatelessWidget {
final ValueChanged<List<String>>? onSelectAll;
final ValueChanged<String>? onToggleSelect;
final ValueChanged<Map<String, dynamic>>? onStopTap;
/// One order's sheet — see [RouteTimeline.onOrderTap].
final ValueChanged<Map<String, dynamic>>? onOrderTap;
final ValueChanged<Map<String, dynamic>>? onNavigate;
final ValueChanged<Map<String, dynamic>>? onCall;
final ValueChanged<RouteGroup>? onPreview;
@@ -753,6 +827,7 @@ class PickupTimelineGroup extends StatelessWidget {
this.onSelectAll,
this.onToggleSelect,
this.onStopTap,
this.onOrderTap,
this.onNavigate,
this.onCall,
this.onPreview,
@@ -777,7 +852,7 @@ class PickupTimelineGroup extends StatelessWidget {
static bool _isChoosable(StopState state) =>
state == StopState.pending ||
(ServiceProfile.active.handsOffAtCollection &&
(state == StopState.accepted || state == StopState.active));
(state == StopState.accepted || state.isAtSource));
/// The one group the rider is working, given a ground of its own.
///
@@ -833,7 +908,7 @@ class PickupTimelineGroup extends StatelessWidget {
// daylight ground. What used to be conveyed by an edge is conveyed by the
// node the header hangs off and by the indent the orders keep to.
return Padding(
padding: EdgeInsets.only(bottom: 24.h),
padding: EdgeInsets.only(bottom: 12.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1023,7 +1098,7 @@ class PickupTimelineGroup extends StatelessWidget {
return Padding(
// Zero on the right: the utilities finish flush with the card's own edge.
// This is the limit — the next point out is outside the card.
padding: EdgeInsets.fromLTRB(_railWidth, 0, 0, 12.h),
padding: EdgeInsets.fromLTRB(_railWidth, 0, 0, 0),
child: wide
? Row(
children: [
@@ -1315,7 +1390,7 @@ class PickupTimelineGroup extends StatelessWidget {
// flush instead: a disc's silhouette curves away from the
// margin, so it needs to overshoot a text edge by a few points
// to look level with it. Optical alignment, not metric.
padding: EdgeInsets.fromLTRB(2.w, 14.h, 6.w, 12.h),
padding: EdgeInsets.fromLTRB(2.w, 8.h, 6.w, 8.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -1448,6 +1523,14 @@ class PickupTimelineGroup extends StatelessWidget {
// the distances line up on one right edge, so "which is
// nearest" is answered by looking down a column rather
// than reading five blocks.
if (group.flat) ...[
SizedBox(width: 4.w),
Icon(
LucideIcons.chevronRight,
size: 20.sp,
color: ColorConstants.borderStrong,
),
],
if (showJourney) ...[
SizedBox(width: 6.w),
_JourneyColumn(
@@ -1460,43 +1543,26 @@ class PickupTimelineGroup extends StatelessWidget {
prominent: isCurrent,
),
],
// ── The disclosure says it is one ──
// ── The chevron is gone; the journey takes the corner ──
//
// The chevron was removed when the header became the
// toggle, and its *signal* went with it: a collapsed
// It came back once as an *indicator* — a passive glyph
// inside the header's own InkWell — because a collapsed
// place showed nothing that said "there is a list under
// me", so the orders were discoverable only by accident
// — on the control the rider uses more than any other
// on this screen.
// me". That argument held while the header's right edge
// was empty.
//
// It returns as an INDICATOR, not a target. The old bug
// was two tap arenas over one pixel; this glyph is
// passive inside the header's own InkWell, so there is
// exactly one gesture and it owns the whole card. It
// rotates with the state (the app's state duration, no
// bounce), and a flat stop — whose tap pushes the stop
// detail instead — wears the pointing variant, the same
// vocabulary every row of Account uses for "this goes
// somewhere".
SizedBox(width: 4.w),
group.flat
? Icon(
LucideIcons.chevronRight,
size: 20.sp,
color: ColorConstants.borderStrong,
)
: AnimatedRotation(
turns: expanded ? 0.5 : 0,
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
child: Icon(
LucideIcons.chevronDown,
size: 20.sp,
color: expanded
? ColorConstants.secondaryText
: ColorConstants.borderStrong,
),
),
// It is not empty: the distance and the ride time live
// there, and they are the reason a rider looks at this
// corner in the first place. The chevron was standing
// between them and the card's edge, so the one column he
// scans down a multi-kitchen run — *which of these is
// nearest* — was pushed 24 points off the right margin
// and no longer lined up with anything.
//
// What the chevron was signalling is still signalled: an
// open group is visibly open (its orders are on screen,
// indented under it), and a flat stop is the one case
// with nothing to open, so it keeps a pointing glyph.
],
),
),
@@ -1604,6 +1670,9 @@ class PickupTimelineGroup extends StatelessWidget {
// breathing chip among outlined ones — so it survives sunlight and a
// grayscale screen. The stop under way used to be hidden from Home
// entirely and announced by a red banner over the foot of the page.
// Both rungs take the live mark — from this rail's distance the useful
// fact is *this is the one he is on* — and each says which rung it is.
StopState.arrived => const LiveMark(label: 'Arrived'),
StopState.active => const LiveMark(),
StopState.collected || StopState.done => const _StateWord(
label: 'Collected',
@@ -1648,91 +1717,34 @@ class PickupTimelineGroup extends StatelessWidget {
Widget _body(BuildContext context, List<BagLine> manifest, int chosen) {
final ids = _choosableIds;
final selectable = _isChoosable(group.state) && onToggleSelect != null;
// ── The orders are a panel *inside* the card ──
// ── The orders are the kitchen's contents, and sit inside it ──
//
// The ground ran the full width of the card and squared off against its
// edges, so it read as a band stuck to the bottom of the header rather than
// as the card's contents — the orders looked like they had spilled out of
// the thing that owns them.
//
// Inset on all four sides with the app's inner radius, it is unambiguously
// *in* the card: a panel with the place's name above it. That is one nested
// surface, not "a collection of cards inside a card" — the orders themselves
// stay rows on it, with no frame of their own.
//
// The inset moves the step rail right by [_panelInset], so the order nodes
// no longer share an axis with the place node above them. That is correct
// now: the spine stopped running between them when the card became the thing
// that groups them (see the note at `lineBelow`), so there is no line to
// bend — and a nested panel that ignored its own container's inset would
// read as broken. `stop_row_alignment_test.dart` pins the new relationship.
// They were flush with the group's own left edge, which put a stop's rail
// on the same axis as the kitchen heading above it — so an open group read
// as two lists that happen to be adjacent rather than as one thing
// containing another. A small indent is the whole signal; the spine does
// the grouping, so it does not need a fill or a box to go with it.
return Padding(
padding: EdgeInsets.fromLTRB(
_panelInset,
0,
_panelInset,
_grounded ? _panelInset : 0,
),
// ── The well is back, because the ground changed under it ──
//
// The tinted panel behind the orders was removed when Home's rows sat
// directly on a white page — a second rectangle then had nothing to
// separate itself from. The route now stands on a white [MilerPanel],
// which put the open list back to white-on-white: an accordion whose
// open state looked exactly like its closed neighbours, which is the
// flatness being fixed on this screen.
//
// So the open orders sit in a canvas-toned well — the one grey that
// measurably stands on this white (1.29:1) and the same tone the page
// ground already is, so the well reads as the card opening *down to the
// page* rather than as a new object. One nested surface, the ladder's
// permitted inset; the rows on it still have no frames of their own.
padding: EdgeInsets.zero,
child: Container(
decoration: _grounded
? BoxDecoration(
color: MilerSurface.canvas,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
)
: null,
// 8 horizontal, not 10: at 320pt and 2.0× text the two extra points
// per side were the difference between fitting and a 31px overflow —
// the accessibility sweep in `home_structure_test` is the referee.
padding: _grounded
? EdgeInsets.fromLTRB(8.w, 8.h, 8.w, 10.h)
: EdgeInsets.only(top: 4.h, bottom: 8.h),
decoration: null,
// 8 → 4. Enough that the rail is visibly inside the group rather than
// on the same axis as its heading, and not so much that the stops read
// as a second column. The indent is the whole signal; it does not need
// to be large to be read.
padding: EdgeInsets.only(left: 4.w, top: 4.h, bottom: 4.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── The journey action lives with the orders ──
//
// It sat on the collapsed card, which read well but put the
// screen's loudest control on every place in the run at once —
// three kitchens, three red buttons, none of them the one he is
// working. Inside the dropdown there is exactly one on screen,
// because exactly one place is open: opening a counter *is* the
// rider saying "this is the one", and the ride to it is what he
// does next.
// ── One control row, not two ──
//
// The journey button sat on its own line *above* the select-all, so
// opening a kitchen produced two stacked rows of chrome before the
// first order — and they are the two things a rider does with an
// open counter, which makes them one row. Select-all leads: it is
// what he does first and the quieter of the two. Navigate closes the
// row on the right, where the primary action belongs.
// No spine on this row. It sits above the first order, so a line
// through it is a length of route drawn above node 1 — the thing
// the rail is supposed to start at.
if (_railRow(context, ids, chosen) != null)
_railed(
_railRow(context, ids, chosen)!,
line: false,
// The tick sits where the order nodes sit, so the label lands
// on the same axis as the customer names it gathers.
node: _selectAllMark(ids, chosen),
// No node: the tick travels with its own label now — see the
// note in [SelectAllAction].
node: null,
nodeSize: _StepNode.size,
),
for (final (i, line) in manifest.indexed)
@@ -1743,14 +1755,15 @@ class PickupTimelineGroup extends StatelessWidget {
selected: selectedIds.contains(line.orderId),
busy: busyIds.contains(line.orderId),
selectable: selectable,
// The spine closes inside the card now: the run's continuity is
// carried by the cards themselves, so the last order's connector
// has nothing below it to reach.
first: i == 0,
last: i == manifest.length - 1,
lineColor: _lineColor,
onTap: () => onToggleSelect?.call(line.orderId),
onOpen: onStopTap == null ? null : () => onStopTap!(line.stop),
// The order's own sheet, not the place's — see
// [RouteTimeline.onOrderTap].
onOpen: onOrderTap == null
? null
: () => onOrderTap!(line.stop),
onUnreject: onUnreject == null
? null
: () => onUnreject!(line.stop),
@@ -1783,24 +1796,6 @@ class PickupTimelineGroup extends StatelessWidget {
///
/// Null when there is nothing to select, so an empty row never reserves height
/// above the first order.
/// The select-all's tick, drawn in the rail's node column.
Widget _selectAllMark(List<String> ids, int chosen) {
final all = chosen == ids.length && ids.isNotEmpty;
final some = chosen > 0 && !all;
final accent = (all || some)
? ColorConstants.primary
: ColorConstants.secondaryText;
return Icon(
all
? LucideIcons.circleCheck
: some
? LucideIcons.circleMinus
: LucideIcons.circleCheck,
size: 18.sp,
color: accent,
);
}
Widget? _railRow(BuildContext context, List<String> ids, int chosen) {
final selectAll = !group.flat && ids.isNotEmpty && onSelectAll != null
? SelectAllAction(
@@ -1820,7 +1815,7 @@ class PickupTimelineGroup extends StatelessWidget {
}
/// The inset that turns the expanded ground into a panel *inside* the card.
static double get _panelInset => 10.w;
static double get _panelInset => 0;
/// Anything that hangs under the header on the group's own spine, so the line
/// runs unbroken past it rather than restarting at the next node.
@@ -1997,159 +1992,91 @@ class PickupTimelineStep extends StatelessWidget {
color: lineColor,
),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: 6.h, right: 2.w),
// ── No tile behind an order ──
//
// Three grounds used to run here — brand for selected, slate for
// accepted, white for pending — each on its own rounded tile.
// Every one of them was a rectangle inside the panel inside the
// card, and with both of those gone the tiles are five loose
// shapes on open ground: a list that reads as five objects
// rather than as five steps of one visit.
//
// Both facts are still carried, and by the thing that is already
// per-order: the **node**. Selected is a filled brand disc with
// a tick; accepted is a filled slate disc; pending is an
// outlined ring with its sequence number. Shape and fill, which
// survive sunlight — and the name's weight follows, so the row
// reads decided or undecided without a wash across it.
child: SizedBox(
width: double.infinity,
child: Row(
children: [
// ── The body ticks ──
//
// The commonest action on a step, so it gets the largest
// target: everything left of the bag block.
Expanded(
child: Semantics(
button: true,
checked: selectable ? selected : null,
label: selectable
? 'Select $customer, ${line.bag}'
: '$customer, ${line.bag}',
excludeSemantics: true,
// ── The body selects; the corner opens ──
//
// Selecting is what a rider does to an order row five
// times per counter; reading its address is what he
// does to one of them occasionally. The frequent action
// gets the large target — the whole row — and the
// occasional one gets the block that already looks like
// a way in: the bag, the order id and a chevron in the
// right corner ([_OrderOpenBlock]).
//
// The two are **siblings**, never nested: two tap
// recognisers over the same pixels resolve to neither,
// which is how the card's chevron came to be dead for a
// build.
//
// It falls back to opening only where there is nothing
// to select, so a settled row is never inert.
child: InkWell(
onTap: selectable && !busy
? onTap
: (canOpen ? onOpen : null),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
child: Padding(
padding: EdgeInsets.fromLTRB(12.w, 9.h, 8.w, 9.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// ── A child of the kitchen, not a sixth kitchen ──
//
// 15sp w500 in the page's mid-tone keeps a name
// comfortably readable outdoors while letting the
// header lead. Ticking promotes it to full slate,
// because a selected row *is* the thing he is
// acting on.
// Weight is the third channel, after the node's
// shape and the tile's ground: a taken order is
// set at w700 in full slate, an undecided one at
// w500 in the mid-tone. Three channels means the
// distinction survives losing any one of them.
style: MilerType.body.copyWith(
fontSize: 15.sp,
fontWeight: selected || accepted
? FontWeight.w600
: FontWeight.w500,
color: rejected
? ColorConstants.secondaryText
: selected || accepted
? ColorConstants.slateText
: ColorConstants.onSurfaceVariant,
decoration: rejected
? TextDecoration.lineThrough
: null,
),
),
// ── Where the bag is going ──
//
// The row carried the customer and the booking
// reference. The reference is what the office reads
// down a phone once, when something has gone wrong;
// the *drop area* is what the rider is deciding on
// every time he looks at this list — it is how he
// knows whether Joe and Priya are the same trip out
// of the kitchen or opposite ends of the city.
//
// So the id moves to the detail sheet, where the
// rare question lives, and the area takes the line
// under the name. Absent rather than blank when the
// payload carries no drop.
if (dropArea.isNotEmpty) ...[
SizedBox(height: 2.h),
child: Container(
decoration: BoxDecoration(
border: last
? null
: Border(
bottom: BorderSide(
color: ColorConstants.borderSubtle,
width: 1.0,
),
),
),
child: Padding(
padding: EdgeInsets.only(bottom: 4.h, top: 4.h, right: 2.w),
child: SizedBox(
width: double.infinity,
child: Row(
children: [
Expanded(
child: Semantics(
button: true,
checked: selectable ? selected : null,
label: selectable
? 'Select $customer, ${line.bag}'
: '$customer, ${line.bag}',
excludeSemantics: true,
child: InkWell(
onTap: selectable && !busy
? onTap
: (canOpen ? onOpen : null),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
child: Padding(
padding: EdgeInsets.fromLTRB(8.w, 6.h, 8.w, 6.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
dropArea,
// ── The area wraps; it does not clip ──
//
// At 2× `Peelamedu` became `SNS…`, which
// is not a shorter answer to "where is
// this going" — it is no answer. Height is
// the cheapest thing on this row to spend,
// and the row is already allowed to grow.
maxLines: 2,
customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 12.5.sp,
style: MilerType.body.copyWith(
fontSize: 15.sp,
fontWeight: selected || accepted
? FontWeight.w600
: FontWeight.w500,
color: rejected
? ColorConstants.borderStrong
: ColorConstants.secondaryText,
? ColorConstants.secondaryText
: selected || accepted
? ColorConstants.slateText
: ColorConstants.onSurfaceVariant,
decoration: rejected
? TextDecoration.lineThrough
: null,
),
),
if (dropArea.isNotEmpty) ...[
SizedBox(height: 2.h),
Text(
dropArea,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 12.5.sp,
color: rejected
? ColorConstants.borderStrong
: ColorConstants.secondaryText,
),
),
],
],
],
),
),
),
),
),
),
// The UNDO branch is gone: a declined order leaves Home for
// Activity the moment it is declined, so there is no row
// here to un-decline from. See `TripCard._showsOnHome`.
//
// ── The bag, the id and the chevron are one target ──
//
// "Which bag" and "which order" are the two things a rider
// reads off a step, and "tell me more about this order" is
// the action they lead to — so they are one block and one
// gesture, marked by the chevron that has always meant
// *opens*.
_OrderOpenBlock(
bag: rejected ? 'Rejected' : line.bag,
orderId: line.orderId,
selected: selected,
onTap: canOpen ? onOpen : null,
),
],
_OrderOpenBlock(
bag: rejected ? 'Rejected' : line.bag,
orderId: line.orderId,
selected: selected,
onTap: canOpen ? onOpen : null,
),
],
),
),
),
),
@@ -2537,7 +2464,7 @@ class _GroupNode extends StatelessWidget {
@override
Widget build(BuildContext context) {
final done = state == StopState.collected || state == StopState.done;
final live = state == StopState.active;
final live = state.isAtSource;
final taken = state == StopState.accepted;
// ── Exactly one red node on the route, and it is the next one ──
@@ -2664,7 +2591,7 @@ class _StepNode extends StatelessWidget {
final done = state == StopState.collected || state == StopState.done;
final rejected = state == StopState.rejected;
final skipped = state == StopState.skipped;
final live = state == StopState.active && !selected;
final live = state.isAtSource && !selected;
final accepted = state == StopState.accepted;
final Color accent = selected

View File

@@ -5,8 +5,6 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:image_picker/image_picker.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/data/bag_manifest.dart';
@@ -16,6 +14,7 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
import 'package:miler/views/helpers/widgets/slide_action.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE RUNG SHEET — one deliberate gesture, at the place it describes.
@@ -220,7 +219,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
(String, bool)? get _instruction {
if (_isArrival || widget.lines.isEmpty) return null;
if (!_canSlide) {
return ('Nothing was handed over — call the hub before you leave.', true);
return ('Nothing was handed over — call your office before you leave.', true);
}
if (_missing.isEmpty) {
return (
@@ -273,7 +272,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
@override
Widget build(BuildContext context) {
return MilerSheetScaffold(
padding: EdgeInsets.fromLTRB(18.w, 0, 18.w, 16.h),
padding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 18.h),
// Scrolls only when it must. At normal scale this sheet fits whole; at
// a 2.0x system text a long manifest plus the photo row outgrows the
// screen by a hair, and a Column that cannot give overflows instead of
@@ -285,19 +284,25 @@ class _StopActionSheetState extends State<StopActionSheet> {
children: [
Row(
children: [
// ── The tile carries the sheet's subject ──
//
// 38 → 46, and the tint resolved rather than laid over: an
// alpha wash takes its colour from whatever is behind it, and
// this sits on a sheet that is white today and was glass
// yesterday. See [ColorConstants.tint].
Container(
width: 38.w,
height: 38.w,
width: 46.w,
height: 46.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _accent.withValues(alpha: 0.12),
color: ColorConstants.tint(_accent, 0.10),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Icon(
_isArrival ? LucideIcons.mapPin : LucideIcons.shoppingBag,
size: 19.sp,
size: 22.sp,
color: _accent,
),
),
@@ -311,19 +316,19 @@ class _StopActionSheetState extends State<StopActionSheet> {
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 17.sp,
fontSize: 19.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
letterSpacing: -0.5,
height: 1.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 3.h),
SizedBox(height: 4.h),
Text(
_subtitle,
style: TextStyle(
fontSize: 12.5.sp,
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
@@ -376,6 +381,17 @@ class _StopActionSheetState extends State<StopActionSheet> {
color: ColorConstants.errorRed,
),
],
// ── The gesture gets its own zone ──
//
// A hairline and real air above it, so the slide reads as the
// sheet's conclusion rather than as the next item in a list. It is
// the only thing on here that writes anything.
SizedBox(height: 18.h),
Divider(
height: 1,
thickness: 1,
color: ColorConstants.borderSubtle,
),
SizedBox(height: 18.h),
_slider(),
SizedBox(height: 6.h),
@@ -607,88 +623,79 @@ class _StopActionSheetState extends State<StopActionSheet> {
);
}
/// ── One slide control, shared with the map sheet ──
///
/// This was `SlideToSubmit.custom` from a package: a 56pt pale-wash pill with
/// a coloured knob, whose internals do not reflow — at 2.0× system text its
/// own row ran off the right edge, so the type had to be clamped to stop it.
///
/// The map sheet's control is the one this app actually designed: 72pt, a
/// solid accent track, a white thumb, a travelling shimmer and three chevrons
/// that say *slide* before anything is touched, a haptic on grab, and a tick
/// on the thumb when it lands. There is no reason the two most consequential
/// gestures in the app should be different objects, and every reason they
/// should not — a rider learns one control and meets it everywhere.
///
/// So it moved out of `map.dart` into [MilerSlideAction] and both sheets use
/// it. The package went with it.
///
/// ── Disabled is a different shape, not a dimmed one ──
///
/// `_canSlide` is false when there is nothing to collect. A slide control at
/// reduced opacity still looks like a slide control and still invites the
/// gesture; the rider drags, nothing happens, and he learns the sheet is
/// broken. So it becomes a plain, obviously-inert bar that says what is
/// missing — the same move `stop_verify`'s blocked CTA makes.
Widget _slider() {
// The slide control is a fixed 52pt pill whose internals come from a
// package and do not reflow — at 2.0x system text its own row ran off the
// right edge. A control that physically cannot grow clamps its type
// instead, the same trade Material's fixed-height chrome makes; the label
// is supplementary ("slide to…") and the gesture itself is the
// affordance, so nothing a rider needs is lost at the cap.
return MediaQuery.withClampedTextScaling(
maxScaleFactor: 1.4,
child: IgnorePointer(
ignoring: !_canSlide,
child: SlideToSubmit.custom(
height: ButtonSizes.primary,
sliderWidth: 46,
padding: const EdgeInsets.all(5),
backgroundDecoration: BoxDecoration(
color: _canSlide
? _accent.withValues(alpha: 0.10)
: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
foregroundDecoration: const BoxDecoration(color: Colors.transparent),
slider: Center(
child: Container(
height: 46,
width: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _canSlide ? _accent : ColorConstants.borderStrong,
shape: BoxShape.circle,
),
child: _busy
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: ColorConstants.onAccent,
),
)
: Icon(
LucideIcons.arrowRight,
size: 20.sp,
color: ColorConstants.onAccent,
),
),
),
hint: IgnorePointer(
child: Row(
children: [
SizedBox(width: 46.0 + 10.w),
Expanded(
child: Text(
_slideLabel,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: _canSlide ? _accent : ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 14.w),
],
),
),
onSubmit: (controller) async {
await _run();
// Reset only if we are still here — a successful slide has popped the
// route and the controller is gone with it.
if (mounted) {
try {
controller.reset();
} catch (_) {}
}
},
if (!_canSlide) {
return Container(
height: MilerSlideAction.height,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
),
child: Text(
_slideLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
);
}
if (_busy) {
// The same footprint as the control it stands in for, so the sheet does
// not jump the moment the rider commits.
return Container(
height: MilerSlideAction.height,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _accent,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: SizedBox(
width: 24.w,
height: 24.w,
child: const CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
);
}
return MilerSlideAction(
label: _slideLabel,
icon: _isArrival ? LucideIcons.mapPin : LucideIcons.package,
color: _accent,
onCommit: _run,
);
}
}

View File

@@ -136,7 +136,9 @@ class StopCard extends StatelessWidget {
this.advanceLabel,
});
bool get _live => state == StopState.active;
/// Both rungs a rider can be standing on. They render the same mark; what
/// differs is the word — see [LiveMark.label].
bool get _live => state == StopState.active || state == StopState.arrived;
bool get _rejected => state == StopState.rejected;
@override
@@ -274,7 +276,7 @@ class StopCard extends StatelessWidget {
/// is the only one that changes.
Widget _statusRow(StopKind kind) {
final chip = _live
? const LiveMark()
? LiveMark(label: state == StopState.arrived ? 'Arrived' : 'Active')
: (state == StopState.pending &&
!ServiceProfile.active.handsOffAtCollection)
? null
@@ -719,7 +721,20 @@ class SelectBox extends StatelessWidget {
/// a `dispose()` on a card that never painted cannot construct a ticker during
/// teardown — the same trap the banner hit, documented in `homepage_banner.dart`.
class LiveMark extends StatefulWidget {
const LiveMark({super.key});
/// The word beside the pulse.
///
/// It was the literal `'Active'`, always. Since `stopStateOf` folded arrival
/// into [StopState.active], sliding *confirm arrival* moved the rung, fired
/// `/reached` and wrote the local record — and then printed **Active** on the
/// row. From the rider's side that is indistinguishable from the arrival not
/// registering at all, which is exactly how it was reported.
///
/// The two states are separate now; this is how the mark says which one it
/// is. `Active` stays the default because the delivery half of the day is
/// where most live stops are.
final String label;
const LiveMark({super.key, this.label = 'Active'});
@override
State<LiveMark> createState() => _LiveMarkState();
@@ -793,7 +808,7 @@ class _LiveMarkState extends State<LiveMark>
SizedBox(width: 6.w),
Flexible(
child: Text(
'Active',
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -834,6 +849,13 @@ class StopStateChip extends StatelessWidget {
color: ColorConstants.serviceAccent,
icon: LucideIcons.shoppingBag,
),
// At the door, not on the road: the pin, not the bike. The two used to
// share this row and the chip printed `Active` for both — see
// [StopState.arrived].
StopState.arrived => (
color: ColorConstants.primary,
icon: LucideIcons.mapPin,
),
StopState.active => (color: ColorConstants.primary, icon: LucideIcons.bike),
StopState.done => (
color: ColorConstants.acceptGreen,

View File

@@ -12,10 +12,8 @@ import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart'
show milerGlassSheet;
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// STOP DETAIL SHEET
@@ -43,6 +41,12 @@ class StopDetailSheet extends StatelessWidget {
/// Stop's position in the route, 1-based.
final int stopNumber;
/// How many stops the route has, when the caller knows. `Stop 3 of 8` is the
/// one thing a rider cannot work out from the card he tapped, and it is the
/// difference between "another stop" and "the last one". Zero means unknown,
/// and then only the position is printed — never an invented total.
final int stopCount;
/// Where the rider is now — draws the "you are here" leg on the map.
final double? riderLat;
final double? riderLng;
@@ -56,14 +60,40 @@ class StopDetailSheet extends StatelessWidget {
/// delivery leg. Empty (Home's caller) leaves the raw kind in charge.
final Set<String> collectedIds;
/// True when this sheet was opened for **one order inside a place**, rather
/// than for a place.
///
/// ── Why an order's map is not its kitchen's map ──
///
/// Home's timeline opens this sheet from two different things. A flat group's
/// header *is* a place — one stop, one counter — and the question it asks is
/// "where is this". An order row inside an expanded kitchen is not: every
/// order in that group shares the same counter, so pinning the counter drew
/// the identical map five times over and told the rider nothing about the
/// order he actually tapped. What distinguishes one order from the next is
/// **where it is going**.
///
/// The place still has a map — a better one, showing the whole leg from where
/// he is standing to the counter with the manifest waiting on it — and it is
/// on the card's own preview, beside the call. See [PickupPreviewSheet]. So
/// the two surfaces stop competing: the card answers *where is this counter*,
/// the order answers *where does this parcel go*.
///
/// Honoured only as far as the payload allows: a row carrying no drop of its
/// own falls back to the pickup, and when it falls back it stops calling
/// itself a drop — see the resolution in [build].
final bool ofOrder;
const StopDetailSheet({
super.key,
required this.stop,
required this.stopNumber,
this.stopCount = 0,
this.riderLat,
this.riderLng,
this.bag = '',
this.collectedIds = const {},
this.ofOrder = false,
});
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
@@ -71,23 +101,29 @@ class StopDetailSheet extends StatelessWidget {
BuildContext context, {
required Map<String, dynamic> stop,
required int stopNumber,
int stopCount = 0,
String bag = '',
Set<String> collectedIds = const {},
double? riderLat,
double? riderLng,
bool ofOrder = false,
}) {
// The tall variant: this sheet opens at 78% of the screen, so it has a
// long way to travel and gets the time to do it.
// ── Not the tall variant any more ──
//
// `large: true` pairs with a sheet that opens at a fixed 78% of the screen
// and therefore has a long way to travel. This one measures itself now, so
// most openings are half that height and the slow entrance read as lag.
return showMilerSheet<void>(
context,
large: true,
builder: (_) => StopDetailSheet(
stop: stop,
stopNumber: stopNumber,
stopCount: stopCount,
bag: bag,
collectedIds: collectedIds,
riderLat: riderLat,
riderLng: riderLng,
ofOrder: ofOrder,
),
);
}
@@ -143,33 +179,43 @@ class StopDetailSheet extends StatelessWidget {
// (`RouteMetricsHelper.metersToStop` has the same pickup-only read and
// feeds the cards' distance column — noted in ABOUT_MILER.md's gaps
// rather than changed here, because every card and ETA reads through it.)
final drop = _isDropLeg;
double leg(List<String> preferred, List<String> fallback) {
final v = _d(stop[preferred[0]] ?? stop[preferred[1]]);
return v != 0 ? v : _d(stop[fallback[0]] ?? stop[fallback[1]]);
}
// ── The leg is resolved once, and every part of the sheet reads it ──
//
// The pin, the eyebrow and the street used to be decided separately, with
// their own fallbacks, which is how a sheet ends up captioning a kitchen's
// address `DELIVER TO`. They are resolved together here: each half is
// "drop" only if the payload actually carries that half, so a partial row
// degrades to the pickup rather than to a contradiction.
final wantsDrop = ofOrder || _isDropLeg;
final lat = drop
? leg(const ['droplat', 'DropLat'], const ['pickuplat', 'PickupLat'])
: _d(stop['pickuplat'] ?? stop['PickupLat']);
final lng = drop
? leg(const ['droplon', 'DropLon'], const ['pickuplon', 'PickupLon'])
: _d(stop['pickuplon'] ?? stop['PickupLon']);
final dropLat = _d(stop['droplat'] ?? stop['DropLat']);
final dropLng = _d(stop['droplon'] ?? stop['DropLon']);
final dropAddress = _s(const [
'dropaddress',
'DropAddress',
'deliveryaddress',
]);
final pinIsDrop = wantsDrop && dropLat != 0 && dropLng != 0;
final addressIsDrop = wantsDrop && dropAddress.isNotEmpty;
// What the distance is measured to, and what the map is centred on.
final drop = pinIsDrop;
final lat = pinIsDrop ? dropLat : _d(stop['pickuplat'] ?? stop['PickupLat']);
final lng = pinIsDrop ? dropLng : _d(stop['pickuplon'] ?? stop['PickupLon']);
final hasLocation = lat != 0 && lng != 0;
final name = _s(['pickupcustomer', 'tenantname']);
final address = drop
? _s([
'dropaddress',
'DropAddress',
'deliveryaddress',
'pickupaddress',
'PickupAddress',
])
final address = addressIsDrop
? dropAddress
: _s(['pickupaddress', 'PickupAddress']);
// The eyebrow names the leg the street below it belongs to — never the
// stop's raw classification, which on an uncollected meal order still says
// pickup while the address under it is the customer's door.
final addressLabel = addressIsDrop ? 'DELIVER TO' : kind.locationLabel;
final phone = _s(['pickupcontactno']);
final notes = _s(['notes', 'Notes']);
final otp = stopOtp(stop);
final deliver = deliveryParcelCount(stop);
final collect = pickupParcelCount(stop);
final cash = stopCollectionAmount(stop);
@@ -184,64 +230,135 @@ class StopDetailSheet extends StatelessWidget {
toDrop: drop,
);
return DraggableScrollableSheet(
initialChildSize: 0.78,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
// The same frosted fabric as every other sheet. This one and the
// preview were the two opaque holdouts — see the sheet kit's audit.
builder: (context, scrollController) => milerGlassSheet(
child: Column(
children: [
const MilerSheetHandle(),
Expanded(
child: ListView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h),
children: [
// ── The sheet is as tall as what is on it ──
//
// It was a [DraggableScrollableSheet] at `initialChildSize: 0.78`, which
// claims 78% of the screen whether or not it has 78% of a screen to say.
// A meal order has a map, one address, one task row and a four-tile grid —
// and it ended at the CONTACT tile with a third of the sheet left over as
// blank paper below it. A surface that reserves space it does not use reads
// as content that failed to load.
//
// [MilerSheetScaffold] is a `mainAxisSize: min` column, so the sheet
// measures its content. The scroll view stays, capped below, for the rows
// that genuinely are long — a five-line Indian address with a note under
// it — and simply does not scroll when there is nothing to scroll.
//
// The drag-to-expand went with it, and nothing was lost: the two sizes it
// offered were "too tall" and "taller", and every other sheet in the app
// (the preview, the rung sheet) has always sized itself this way.
return ConstrainedBox(
// ── A ceiling on the WHOLE sheet, not on its list ──
//
// Constraining the scroll view instead left the handle and the bottom
// inset outside the budget, so a sheet capped at 86% measured 91% — the
// cap missed by exactly its own chrome. Wrapping the scaffold makes the
// number mean what it says: past this the sheet stops growing and the
// list inside it starts scrolling, which keeps the scrim — and the route
// behind it — visible at the top on every phone.
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.86,
),
child: MilerSheetScaffold(
padding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 18.h),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(kind, name),
SizedBox(height: 14.h),
if (hasLocation) ...[
_map(lat, lng, kind),
SizedBox(height: 10.h),
_distanceLine(meters),
SizedBox(height: 16.h),
// ── The leg is stated once ──
//
// It was stated twice, twenty points apart: a strip across
// the foot of the map, and a DISTANCE / EST. ARRIVAL pair
// under it. Two readings of one fact, and the second cost
// ~70pt on a sheet a rider already has to scroll — which is
// the whole reason it felt long.
//
// The strip stays, because it is written on the picture of
// the leg it describes, and it carries both halves: how far
// and how long.
_map(lat, lng, kind, meters),
SizedBox(height: 18.h),
],
if (address.isNotEmpty) ...[
_sectionLabel(kind.locationLabel),
SizedBox(height: 5.h),
// The full address, unabridged. The card above compresses it
// to two lines; this is where the rest lives.
SelectableText(
address,
style: TextStyle(
fontSize: 14.5.sp,
height: 1.45,
fontWeight: FontWeight.w500,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
_sectionLabel(addressLabel),
SizedBox(height: 8.h),
// ── The address is a block, not a paragraph ──
//
// It was loose text on the sheet's own white, at the same
// weight as everything around it, and a full Indian address
// is five lines long — so the thing a rider came here to
// read had nothing holding it. On a tonal block it is one
// object the eye can land on, and it is plainly the answer
// to the label above it.
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 13.h,
),
decoration: BoxDecoration(
color: Narrative.inset,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
// The full address, unabridged. The card above compresses
// it to two lines; this is where the rest lives.
child: SelectableText(
address,
style: TextStyle(
fontSize: 14.sp,
height: 1.5,
fontWeight: FontWeight.w500,
color: Narrative.ink,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(height: 16.h),
SizedBox(height: 18.h),
],
_sectionLabel('WHAT TO DO HERE'),
_sectionLabel('TASK DETAILS'),
SizedBox(height: 8.h),
// ── The call sits on the job, not at the foot of the sheet ──
//
// Ringing the customer is not a step of the hand-over, it is
// what you do *about* the hand-over — a wrong door, a gate
// code, "I'm outside". As a full-width labelled button at the
// bottom it took a third of the sheet's action area and read
// as an alternative to delivering. As a disc on the row that
// names the job it is exactly what it is: one tap, beside the
// person it calls.
if (kind.hasDelivery)
_actionRow(
icon: LucideIcons.truck,
color: ColorConstants.deliveryAccent,
color: Narrative.burgundy,
title: _countLabel('Deliver', deliver),
detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof',
// ── No OTP is asked for at the door ──
//
// Nothing issues a delivery code on this backend — the
// `deliver` endpoint has no OTP column and cannot check
// one (see [MilerApi.deliver]) — so the sheet was
// instructing riders to collect a number that does not
// exist and that nobody would verify. The proof is the
// photo of the parcel, and now that is all it says.
detail: 'Photo of the parcel',
trailing: phone.isEmpty
? null
: _CallDisc(onTap: () => _dial(phone)),
),
if (kind.hasPickup)
_actionRow(
icon: LucideIcons.package,
color: ColorConstants.pickupAccent,
color: Narrative.burgundy,
title: _countLabel('Collect', collect),
detail: cash > 0
? 'Collect ₹${cash.round()}'
: 'Photo + weight',
detail: cash > 0 ? 'Collect ₹${cash.round()}' : 'Photo',
trailing: (phone.isEmpty || kind.hasDelivery)
? null
: _CallDisc(onTap: () => _dial(phone)),
),
SizedBox(height: 6.h),
_detailGrid(),
@@ -249,12 +366,16 @@ class StopDetailSheet extends StatelessWidget {
SizedBox(height: 16.h),
_noteBox(notes),
],
SizedBox(height: 20.h),
_actions(context, phone, lat, lng, hasLocation),
],
),
),
],
// ── The sheet has no action bar ──
//
// It closed with **Call** beside **Navigate to customer**,
// and neither belonged. This is the *reference* surface —
// what is at this stop, where exactly, what it weighs, who
// to ring — opened from a card that already carries the
// stop's own action. Navigation is the map screen's whole
// job, and calling is now a disc on the row above.
],
),
),
),
);
@@ -274,12 +395,21 @@ class StopDetailSheet extends StatelessWidget {
children: [
// Tonal, like every disc in the app — this was the last solid-filled
// badge left, a saturated coloured coin above the customer's name.
// ── One accent on this sheet, and it is the brand ──
//
// The disc, the task tile and the call all took `kind.accent` — blue
// on a delivery, orange on a mixed stop — which is a *classification*
// colour: it exists so a rider can tell two kinds of stop apart in a
// list. There is one stop on this sheet and he already knows which, so
// the colour was classifying nothing and spending three tints doing
// it. The brand marks the sheet's own furniture instead, which is what
// the rest of the app does.
Container(
width: 34.w,
height: 34.w,
width: 38.w,
height: 38.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kind.accent.withValues(alpha: 0.12),
color: Narrative.burgundyWash,
shape: BoxShape.circle,
),
child: Text(
@@ -287,7 +417,7 @@ class StopDetailSheet extends StatelessWidget {
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: kind.accent,
color: Narrative.burgundy,
fontFamily: FontConstants.fontFamily,
),
),
@@ -307,22 +437,45 @@ class StopDetailSheet extends StatelessWidget {
// lines. The name leads now, as it does on every card.
Text(
name.isEmpty ? 'Stop $stopNumber' : name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
fontSize: 19.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.15,
color: ColorConstants.slateText,
color: Narrative.ink,
fontFamily: FontConstants.fontFamily,
),
),
// ── The one fact the sheet never stated ──
// ── Where this stop sits in the run ──
//
// Everything else here is reference; where this stop IS in its
// lifecycle was only knowable from the card underneath the
// barrier. The word, glyph and colour come from the canonical
// vocabulary ([StopStatusX]) — never a switch written here.
_statusLine(),
// The disc carries the number and the disc alone cannot say what
// it is a number *of*. `Stop 3 of 8` is the fact a rider cannot
// reconstruct from the card he tapped, and it is the difference
// between another stop and the last one. The total is printed
// only when the caller knows it — see [stopCount].
SizedBox(height: 2.h),
Text(
stopCount > 0
? 'Stop $stopNumber of $stopCount'
: 'Stop $stopNumber',
maxLines: 1,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
),
// ── The lifecycle word is not printed here ──
//
// "Out for delivery" sat under the customer's name and said
// nothing the rider did not already know: he is holding the
// parcel, he opened this sheet from the Deliveries tab, and the
// card he opened it from carries the same word. A second, larger
// copy of it directly under the name pushed the address — the
// fact he came for — a line further down every time.
],
),
),
@@ -330,34 +483,6 @@ class StopDetailSheet extends StatelessWidget {
);
}
Widget _statusLine() {
final status = stopStatusOf(stop);
if (status == StopStatus.unknown) return const SizedBox.shrink();
return Padding(
padding: EdgeInsets.only(top: 5.h),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(status.icon, size: 14.sp, color: status.color),
SizedBox(width: 5.w),
Flexible(
child: Text(
status.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
color: status.color,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
/// A small, non-interactive map. Orientation only — it answers "which side of
/// town is this?" in one glance and then gets out of the way.
///
@@ -377,8 +502,11 @@ class StopDetailSheet extends StatelessWidget {
/// lite mode was buying — not building a platform view during the entrance —
/// is already paid for by [AfterEntrance] holding it back, so nothing is lost
/// by dropping it.
Widget _map(double lat, double lng, StopKind kind) {
return ClipRRect(
Widget _map(double lat, double lng, StopKind kind, double? meters) {
final distance = RouteMetricsHelper.formatDistance(meters);
final ride = RouteMetricsHelper.travelTime(meters);
final frame = ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: SizedBox(
height: 170.h,
@@ -415,53 +543,70 @@ class StopDetailSheet extends StatelessWidget {
),
),
);
}
Widget _distanceLine(double? meters) {
final distance = RouteMetricsHelper.formatDistance(meters);
final travel = RouteMetricsHelper.travelTime(meters);
if (distance == '—') return const SizedBox.shrink();
if (distance == '—') return frame;
// Both figures are Flexible: the pair was rigid, and rigid text in a Row
// is a right-edge overflow waiting for a large text scale — the sheet
// sweep caught exactly that. The distance keeps priority; the ride time
// is the half that gives way.
return Row(
// ── The leg is written on the picture of the leg ──
//
// The distance used to be a line of text under the map, which is a caption
// for a thing that can carry its own: the map is the answer to "where is
// this", the strip is the answer to "how far", and a rider reads them in
// one look instead of two. Laid over a scrim so it holds on a pale street
// grid and on a dark one.
return Stack(
children: [
Icon(
LucideIcons.navigation,
size: 16.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
'$distance away',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
frame,
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ClipRRect(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(DesignConstants.radiusXl),
),
),
),
if (travel > Duration.zero) ...[
Flexible(
child: Text(
' · ${RouteMetricsHelper.formatDuration(travel)} ride',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
color: ColorConstants.pureSurface.withValues(alpha: 0.88),
child: Row(
children: [
Icon(
LucideIcons.navigation,
size: 13.sp,
color: Narrative.ink,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
'$distance away',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: Narrative.ink,
fontFamily: FontConstants.fontFamily,
),
),
),
if (ride > Duration.zero)
Flexible(
child: Text(
' · ${RouteMetricsHelper.formatDuration(ride)} ride',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
),
],
),
],
);
}
@@ -472,7 +617,7 @@ class StopDetailSheet extends StatelessWidget {
fontSize: 10.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: ColorConstants.secondaryText,
color: Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
);
@@ -482,21 +627,39 @@ class StopDetailSheet extends StatelessWidget {
required Color color,
required String title,
required String detail,
/// Sits at the right of the row — the call disc, on the row that names
/// the person to call. Absent when the payload carries no number: a dead
/// control in a corner is worse than an empty corner.
Widget? trailing,
}) {
return Padding(
padding: EdgeInsets.only(bottom: 8.h),
// ── The job is a card, not a line ──
//
// It sat as a bare row under an eyebrow, which put the one instruction on
// the sheet — what to actually do at this door — at the same weight as the
// reference facts under it. On its own tonal card it is the block the
// section heading promised, and the call disc has something to sit on
// instead of floating at the right margin.
return Container(
margin: EdgeInsets.only(bottom: 8.h),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: Narrative.inset,
borderRadius: BorderRadius.circular(Narrative.radiusInner),
),
child: Row(
children: [
Container(
width: 32.w,
height: 32.w,
width: 38.w,
height: 38.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(Narrative.radiusInner),
),
child: Icon(icon, size: 17.sp, color: color),
child: Icon(icon, size: 18.sp, color: color),
),
SizedBox(width: 10.w),
SizedBox(width: 11.w),
// ── The task and what it asks for, stacked ──
//
// These were side by side, with the requirement pushed hard against
@@ -516,7 +679,7 @@ class StopDetailSheet extends StatelessWidget {
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
color: Narrative.ink,
fontFamily: FontConstants.fontFamily,
),
),
@@ -527,7 +690,7 @@ class StopDetailSheet extends StatelessWidget {
Icon(
LucideIcons.squareCheckBig,
size: 12.sp,
color: ColorConstants.secondaryText,
color: Narrative.inkMuted,
),
SizedBox(width: 4.w),
Flexible(
@@ -539,7 +702,7 @@ class StopDetailSheet extends StatelessWidget {
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
color: Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
),
@@ -550,6 +713,7 @@ class StopDetailSheet extends StatelessWidget {
],
),
),
if (trailing != null) ...[SizedBox(width: 10.w), trailing],
],
),
);
@@ -582,46 +746,37 @@ class StopDetailSheet extends StatelessWidget {
if (rows.isEmpty) return const SizedBox.shrink();
// ── The facts are tiles on the ground, not cells in a table ──
//
// They were one white box cut by hairlines, on a white sheet — a table
// drawn on the same surface it sits on, which is a lot of line-work to say
// "these are facts". Two tonal tiles to a row instead: the same pairing,
// no rules to read past, and the same object the address and the task card
// above them are made of.
return Padding(
padding: EdgeInsets.only(top: 16.h),
child: Container(
padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 4.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
),
child: Column(
children: [
for (var i = 0; i < rows.length; i += 2) ...[
if (i > 0)
Container(height: 1, color: ColorConstants.borderSubtle),
// Intrinsic, so the vertical hairline takes the height of the
// taller of the two tiles. `stretch` alone asks for infinity
// inside a scrolling column.
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _factTile(rows[i])),
// A single trailing fact takes the full row rather than half
// of one — a lone tile beside empty space reads as a missing
// value, which on a sheet full of real ones is alarming.
if (i + 1 < rows.length) ...[
Container(width: 1, color: ColorConstants.borderSubtle),
Expanded(child: _factTile(rows[i + 1])),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < rows.length; i += 2) ...[
if (i > 0) SizedBox(height: 8.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _factTile(rows[i])),
// A single trailing fact takes the full row rather than half
// of one — a lone tile beside empty space reads as a missing
// value, which on a sheet full of real ones is alarming.
if (i + 1 < rows.length) ...[
SizedBox(width: 8.w),
Expanded(child: _factTile(rows[i + 1])),
],
),
],
),
],
// ── Rules between rows, not around every cell ──
//
// A grid drawn as boxes is a spreadsheet; drawn as one surface cut
// by hairlines it is a set of facts. The last row gets none, so
// the block closes on its own edge.
),
],
),
],
),
);
}
@@ -641,8 +796,12 @@ class StopDetailSheet extends StatelessWidget {
/// it. Two to a row, so the whole set is scanned in a couple of saccades
/// rather than seven.
Widget _factTile(MapEntry<String, String> fact) {
return Padding(
padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h),
return Container(
padding: EdgeInsets.fromLTRB(13.w, 11.h, 13.w, 11.h),
decoration: BoxDecoration(
color: Narrative.inset,
borderRadius: BorderRadius.circular(Narrative.radiusInner),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@@ -655,7 +814,7 @@ class StopDetailSheet extends StatelessWidget {
fontSize: 9.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.9,
color: ColorConstants.secondaryText,
color: Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
),
@@ -679,7 +838,7 @@ class StopDetailSheet extends StatelessWidget {
height: 1.25,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
color: Narrative.ink,
fontFamily: FontConstants.fontFamily,
),
),
@@ -694,7 +853,7 @@ class StopDetailSheet extends StatelessWidget {
height: 1.25,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
color: Narrative.ink,
fontFamily: FontConstants.fontFamily,
),
),
@@ -708,7 +867,7 @@ class StopDetailSheet extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
decoration: BoxDecoration(
color: ColorConstants.warningContainer,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderRadius: BorderRadius.circular(Narrative.radiusInner),
border: Border.all(
color: ColorConstants.warning.withValues(alpha: 0.35),
),
@@ -739,55 +898,6 @@ class StopDetailSheet extends StatelessWidget {
);
}
Widget _actions(
BuildContext context,
String phone,
double lat,
double lng,
bool hasLocation,
) {
return Row(
children: [
if (phone.isNotEmpty) ...[
Expanded(
child: MilerButton(
label: 'Call',
icon: LucideIcons.phone,
variant: MilerButtonVariant.outlined,
color: ColorConstants.acceptGreen,
onPressed: () => _dial(phone),
),
),
SizedBox(width: 10.w),
],
Expanded(
flex: 2,
child: Builder(
builder: (_) {
// ── Where this button goes depends on the stop's stage ──
//
// Before collection it opens the kitchen; after collection it
// opens the customer. The rule lives in [MilkRun] so the sheet,
// the card and the deliveries tab cannot drift apart about it —
// and it returns null rather than guessing when the stop carries
// no coordinates for the leg it is on.
final target = MilkRun.navigationTarget(stop);
final toCustomer = MilkRun.navigatesToCustomer(stop);
return MilerButton(
label: toCustomer ? 'Navigate to customer' : 'Navigate',
icon: LucideIcons.navigation,
color: ColorConstants.primary,
onPressed: target == null
? null
: () => _navigate(target.lat, target.lng),
);
},
),
),
],
);
}
Future<void> _dial(String phone) async {
final clean = phone.replaceAll(RegExp(r'[^\d+]'), '');
if (clean.isEmpty) return;
@@ -798,21 +908,52 @@ class StopDetailSheet extends StatelessWidget {
);
} catch (_) {}
}
}
Future<void> _navigate(double lat, double lng) async {
// Native intent first, web as the fallback — same order as the stop screen.
try {
final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d');
if (await launchUrl(native, mode: LaunchMode.externalApplication)) return;
} catch (_) {}
try {
await launchUrl(
Uri.parse(
'https://www.google.com/maps/dir/?api=1'
'&destination=$lat,$lng&travelmode=driving',
/// The call, as a disc.
///
/// Tonal rather than filled: it supports the job on the row rather than being
/// it.
///
/// ── And green, like every other phone in the app ──
///
/// It was brand for a while, on the argument that this sheet had settled on a
/// single accent and a lone green disc among rose tints reads as borrowed from
/// another screen. That argument was about the *sheet*; the rider's is about
/// the control. Green is what a handset means before any palette gets a say —
/// it is the colour of the phone on the map sheet, on the queue card and on
/// every app he already carries — and a call button that changes hue by screen
/// is a control he has to re-find. Consistency across the app beats
/// consistency within one surface.
class _CallDisc extends StatelessWidget {
final VoidCallback onTap;
const _CallDisc({required this.onTap});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Call this customer',
child: Material(
color: Narrative.emeraldWash,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
// At the accessibility floor. This is pressed one-handed, at a
// door, often in the rain.
width: ButtonSizes.minTapTarget,
height: ButtonSizes.minTapTarget,
child: Icon(
LucideIcons.phone,
size: 18.sp,
color: Narrative.emerald,
),
),
),
mode: LaunchMode.externalApplication,
);
} catch (_) {}
),
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/data/route_order.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/work_domain.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A TRIP — one slot's worth of work, as a single unit.
@@ -105,6 +106,16 @@ enum DayPart {
///
/// Trip 2 is the afternoon even on a day with no morning work — the number is
/// the day part, so the rider's "Trip 2" and the hub's are the same run.
///
/// ── This split is the client's, and the backend has confirmed it ──
///
/// Asked directly (24 Aug 2026): *does the console own Trip 1/2/3?* Answer:
/// **no — there is no `tripid` or `slotid` backend-side.** The day-part
/// grouping is therefore documented client behaviour rather than a guess
/// standing in for a field that exists, and the app is not waiting on one.
///
/// If that ever changes, this is the class to delete: the ids would arrive on
/// the booking row and the split would stop being derived at all.
extension TripSlots on List<Trip> {
/// The day laid out as slots: index 0 is the morning, 1 the afternoon, 2 the
/// evening, and an empty slot is a part of the day with no work in it.
@@ -212,6 +223,61 @@ class Trip {
int get stopCount => stops.length;
/// The same trip, recounted over **only the work still owed on Home**.
///
/// ── Why the brief was reading the wrong number ──
///
/// The four figures in [TripBriefStrip] — duration, distance, parcels,
/// payment — were computed once, over every stop the slot ever held, and
/// never moved again. The headline above them says `20 stops left` and
/// counts down as the rider works; the figures under it kept describing the
/// morning he started with.
///
/// So a rider with five collections to go read `≈4h 15m · 29.0 km · 25
/// parcels` — the whole day, including the twenty stops already in his box
/// and gone to Deliveries. The one question the card exists to answer, *what
/// is left in front of me*, was the one thing on it that was stale.
///
/// ── Where the line is ──
///
/// [WorkBoundary.pickupComplete] — the same boundary Home and Deliveries
/// already split on, so a stop cannot be counted here and owned there. Once
/// the collection is done the stop belongs to the delivery leg and is not
/// this card's business. Skipped and cancelled work is dropped for the same
/// reason: it is a record now, not a stop to plan around.
///
/// [origin] is the point the round trip is measured from — the rider's own
/// position, which is what the page passes to [Trip.fromStops] as the hub.
/// Passing it keeps the recount on the same footing as the original
/// measurement; omitting it would silently fall back to per-stop kilometres
/// and read as a distance that changed for no reason.
///
/// Returns `this` unchanged when nothing has been collected yet, so the
/// common case allocates nothing.
Trip outstanding({
Set<String> collectedIds = const <String>{},
double? originLat,
double? originLng,
}) {
final left = <Map<String, dynamic>>[
for (final s in stops)
if (!WorkBoundary.pickupComplete(s, collectedIds: collectedIds) &&
!stopStatusOf(s).isCancelled &&
!stopStatusOf(s).isSkipped)
s,
];
if (left.length == stops.length) return this;
return Trip.fromStops(
id: id,
stops: left,
slotStart: slotStart,
slotEnd: slotEnd,
hubLat: originLat,
hubLng: originLng,
);
}
/// The number the rider is actually planning around: ride time plus every
/// minute spent standing at a door.
Duration get totalDuration => travelDuration + serviceDuration;
@@ -847,6 +913,24 @@ enum StopState {
/// [ServiceProfile.handoffAt] and the collected store.
collected,
/// He is **at** the stop and has said so — the arrival is recorded, the
/// pickup is not yet made.
///
/// ── Why this is not [active] ──
///
/// It was. `stopStateOf` folded `arrived` and `active` into one value, so the
/// Home row had no arrived to render and the live chip printed the literal
/// word **Active** the moment a rider slid *confirm arrival*. He was told his
/// arrival had not registered, when in fact it had: the rung moved, the API
/// fired, the store was written, and the one word on screen said something
/// else.
///
/// They are different facts. Arrived is a rung on the pickup ladder that the
/// rider puts the stop on. Active is an operational statement about which
/// stop is being worked — see `_activePickupOrderId` — and it is the word the
/// backend reserves for a delivery under way.
arrived,
/// The rider is physically on this stop right now.
active,
@@ -874,12 +958,35 @@ extension StopStateX on StopState {
bool get isCommitted =>
this == StopState.accepted ||
this == StopState.collected ||
this == StopState.active ||
this.isAtSource ||
this == StopState.skipped;
/// In the rider's hands right now.
bool get isCollected => this == StopState.collected;
/// He is **at** the source and the pickup is not yet made.
///
/// ── Why this is a predicate and not a comparison ──
///
/// [StopState.arrived] and [StopState.active] were one value. Splitting them
/// apart is what finally let the row say *Arrived* instead of *Active* — and
/// it silently changed the answer at every site that had written
/// `== StopState.active` to mean **is he standing at the counter**. There
/// were a dozen, and each of them began answering `false` for the one rung
/// whose entire point is that he is standing there:
///
/// • Home's visibility filter — so a stop *left the screen* the moment he
/// marked it arrived, and Deliveries would not take it either because
/// arrival is pre-pickup. The stop was in neither place.
/// • the selection bar's action, which offered *Accept* for a stop he was
/// already at
/// • the card's own **Mark as picked** button, which stopped being built
/// • which group opens by default, and which one counts as live
///
/// One question asked in one place, so the next rung added to the ladder
/// cannot quietly repeat it.
bool get isAtSource => this == StopState.arrived || this == StopState.active;
/// What this state is called on a card.
///
/// ── Two vocabularies, one state machine ──
@@ -899,6 +1006,7 @@ extension StopStateX on StopState {
StopState.pending => 'Awaiting your decision',
StopState.accepted => 'Accepted',
StopState.collected => 'Collected',
StopState.arrived => 'Arrived',
StopState.active => 'In progress',
StopState.done => 'Completed',
StopState.skipped => 'Skipped',
@@ -908,6 +1016,7 @@ extension StopStateX on StopState {
StopState.pending => 'Pending',
StopState.accepted => 'Accepted',
StopState.collected => 'Collected',
StopState.arrived => 'Arrived',
StopState.active => 'Out for delivery',
StopState.done => 'Delivered',
StopState.skipped => 'Skipped',
@@ -946,8 +1055,11 @@ StopState stopStateOf(
return StopState.done;
}
// 2. The rider is physically on the stop — also not a local decision.
if (raw == 'active' || raw == 'arrived') return StopState.active;
// 2. He is at the stop — also not a local decision.
//
// Two values, not one. Folding them lost the arrival: see [StopState.arrived].
if (raw == 'arrived') return StopState.arrived;
if (raw == 'active') return StopState.active;
if (raw == 'skipped') return StopState.skipped;
// 2b. Released for delivery — which `pickup-complete` does by itself on

View File

@@ -70,6 +70,19 @@ import 'package:miler/views/helpers/constants/Font_constant.dart';
/// ─────────────────────────────────────────────────────────────────────────
class TripBriefStrip extends StatefulWidget {
final Trip trip;
/// The same trip recounted over **only the stops still owed a collection**.
///
/// The two are deliberately separate. [trip] is the slot as assigned and is
/// what the progress head measures against — `20 stops left` only means
/// anything against a total that does not move. [remaining] is the work in
/// front of the rider right now, and it is what the four figures describe.
///
/// Merging them would break the head; leaving it out is what made the
/// figures describe a morning the rider had already half finished. See
/// [Trip.outstanding].
final Trip remaining;
final RouteBrief brief;
/// Completion across the trip, 0–100. Supplied by the page because it is
@@ -85,7 +98,8 @@ class TripBriefStrip extends StatefulWidget {
required this.brief,
required this.percent,
this.onViewRoute,
});
Trip? remaining,
}) : remaining = remaining ?? trip;
@override
State<TripBriefStrip> createState() => _TripBriefStripState();
@@ -107,9 +121,22 @@ class TripBriefStrip extends StatefulWidget {
/// two he acts on. The rest is one tap away and remembers nothing between
/// builds, because a strip that reopens itself is furniture that grows back.
class _TripBriefStripState extends State<TripBriefStrip> {
bool _open = false;
/// ── Open on arrival ──
///
/// It was closed, which meant the four journey figures were shipped and
/// unreachable: the chevron was pressed rarely enough that a rider could not
/// see the day's duration, distance, parcel count or cash without discovering
/// a control nobody told him about.
///
/// Open, and the chevron becomes what it should always have been — a way to
/// put the reference material away once it has been read, on a screen whose
/// subject is the route below it.
bool _open = true;
Trip get _trip => widget.trip;
/// What is still in front of the rider — see [TripBriefStrip.remaining].
Trip get _left => widget.remaining;
RouteBrief get _brief => widget.brief;
int get percent => widget.percent;
VoidCallback? get onViewRoute => widget.onViewRoute;
@@ -158,6 +185,17 @@ class _TripBriefStripState extends State<TripBriefStrip> {
child: _head(),
),
),
// ── What the chevron actually controls ──
//
// The four figures were moved onto the head for a while, so they were
// always drawn — and the chevron above them went on rotating while
// nothing under it moved. A disclosure that does not disclose is worse
// than no disclosure: the rider presses it, the arrow turns, the card
// stays the same size, and he learns the control is broken.
//
// They are back under the fold and the fold **opens by default**, so
// the card still arrives showing them. The difference is that closing
// it now closes them, which is what the arrow has been promising.
ClipRect(
child: AnimatedSize(
duration: DesignConstants.motionState,
@@ -214,8 +252,21 @@ class _TripBriefStripState extends State<TripBriefStrip> {
// own caption ("9 of 24 done"), 6pt with rounded caps and a visible
// determinate fill — the grammar of completion, not of waiting. Colour
// follows the day: brand while the run is live, green when it closes.
// ── No vertical padding of its own, and that is the whole bug ──
//
// This carried `symmetric(vertical: 14)` while the [MilerPanel] wrapping it
// carries `MilerSurface.panelPad` — 16 — on the same two edges. So the head
// sat inside **30 points** of padding above and below, from two widgets
// neither of which could see the other's, and the headline read as floating
// in a band of air.
//
// The panel owns the card's inset, on all four sides. It is the widget that
// knows where the card's edge is; the head is content inside it and content
// does not pad itself off a boundary it cannot see. The gaps *between* the
// three things in here — headline, bar, journey line — are the head's, and
// those are the eights below.
return Padding(
padding: EdgeInsets.symmetric(vertical: 14.h),
padding: EdgeInsets.zero,
// Everything in the hero is a figure or a figure's caption, and the
// card's own precedent holds: numbers are read at a glance, not
// studied, so they stop scaling at 1.3× while the prose elsewhere
@@ -236,7 +287,18 @@ class _TripBriefStripState extends State<TripBriefStrip> {
color: ColorConstants.acceptGreen,
),
SizedBox(width: 8.w),
Flexible(
// ── Expanded, not Flexible-plus-Spacer ──
//
// The row was `Flexible(text)` … `Spacer()`, and both are
// flex 1: the free width was split down the middle, so the
// caption took half of it and the chevron came to rest in
// the *middle of the card* instead of on its right edge. A
// disclosure that is not on an edge reads as a stray glyph
// beside the words rather than as the control for the panel.
//
// The caption takes the width and the chevron closes the
// row, which is where every other disclosure in the app sits.
Expanded(
child: Text(
'All $total done',
maxLines: 1,
@@ -251,38 +313,50 @@ class _TripBriefStripState extends State<TripBriefStrip> {
),
),
] else ...[
Text(
'$left',
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w800,
letterSpacing: -1,
height: 1.0,
fontFeatures: const [FontFeature.tabularFigures()],
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 7.w),
Flexible(
child: Padding(
padding: EdgeInsets.only(top: 6.h),
child: Text(
left == 1 ? 'stop left' : 'stops left',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
// ── One phrase, centred, at one weight ──
//
// It was a 28pt numeral with `stops left` set beside it at 14
// and pushed down six points to sit on its baseline — two
// sizes, two weights and a manual nudge to make a figure and
// its unit look like one thing. They are one thing, so they
// are one string.
//
// Centred because this is the card's title and the card is
// symmetrical: the bar under it runs the full width, and a
// left-aligned headline over a centred bar reads as two
// elements that failed to line up.
Expanded(
child: Text(
left == 1 ? '1 stop left' : '$left stops left',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
// ── 1.2 → 1.0 ──
//
// Line height on a **single** line is not spacing, it
// is invisible padding baked into the glyph box: at 1.2
// a 17pt headline carried ~3.4pt above and below that no
// `SizedBox` accounts for and nobody can find by reading
// the layout. Stacked on the card's own vertical padding
// and the gap under the row, it is what made the space
// around `17 stops left` look like a mistake.
//
// One line needs no leading. The gaps either side of it
// are the gaps either side of it.
height: 1.0,
fontFeatures: const [FontFeature.tabularFigures()],
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
const Spacer(),
if (cash > 0) ...[_cashChip(cash), SizedBox(width: 8.w)],
if (cash > 0) ...[SizedBox(width: 8.w), _cashChip(cash)],
SizedBox(width: 8.w),
AnimatedRotation(
turns: _open ? 0.5 : 0,
duration: DesignConstants.motionState,
@@ -295,7 +369,10 @@ class _TripBriefStripState extends State<TripBriefStrip> {
),
],
),
SizedBox(height: 12.h),
// 12 → 8. The bar measures what the headline states, so they are
// one statement and the gap between them should read as tighter
// than the gap around the pair.
SizedBox(height: 8.h),
// The track is the page's own canvas tone — the one grey that
// measurably separates from this white card (1.29:1) — and the
// fill starts from zero honestly: an untouched run shows an empty
@@ -324,29 +401,41 @@ class _TripBriefStripState extends State<TripBriefStrip> {
),
),
),
SizedBox(height: 9.h),
// ── `33 of 50 done` came off this line ──
//
// The header states it now, on the trip tab it belongs to, and
// this card sits directly under the header — so the same ratio was
// being read twice within about 150 points of screen, once as a
// tab's metadata and once as a caption. It was also the third
// statement of the same fact on the card itself: the numeral above
// is `left` and the bar between them is `done ÷ total`.
//
// What is left under the bar is the journey — how long and how far
// — which nothing else on this screen says.
//
// It went flush against the bar when the ratio came off this line,
// because the gap belonged to the line that was removed. Eight,
// matching the one above the bar, so the bar sits evenly between
// the two things it separates.
SizedBox(height: 8.h),
Row(
children: [
Text(
'$_done of $total done',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
// ── The journey leads the line now ──
//
// It used to be the *fallback* — shown only when there was no
// shift and no slot, because the clock had the row. With the
// ratio gone there is space for both, and how long and how far
// is the pair a rider is actually planning against. It yields
// while the grid below is open so nothing prints twice.
Flexible(
child: _open ? const SizedBox.shrink() : _journeyGlance(),
),
const Spacer(),
// The clock keeps its own colour rules — the one line here
// allowed to carry bad news; see [_timeLeftLine]. With no
// shift and no slot the journey speaks instead, and yields
// while the grid below is open so nothing prints twice.
Flexible(
child: (!_brief.shiftSet && _trip.slotStart == null)
? (_open ? const SizedBox.shrink() : _journeyGlance())
: _timeLeftLine(),
),
// allowed to carry bad news; see [_timeLeftLine]. It prints
// nothing at all when the hub has set neither a shift nor a
// slot, which is when the journey has the row to itself.
Flexible(child: _timeLeftLine()),
],
),
],
@@ -373,7 +462,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
// time it shared a row with the headline, both flexible.
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
alignment: Alignment.centerLeft,
child: Text(
parts.join(' \u00b7 '),
maxLines: 1,
@@ -483,12 +572,11 @@ class _TripBriefStripState extends State<TripBriefStrip> {
// VIEW ROUTE is not here any more either; it is an action, and it now sits on
// the START · HUB row where the route actually begins. See `_hubRow`.
Widget _body() {
final cash = _trip.cashToCollect;
final duration = roundTripDuration(_trip.totalDuration);
return Padding(
// Horizontally flush with [_head], for the same reason.
padding: EdgeInsets.only(bottom: 14.h),
// Nothing at the foot either: the panel's own `panelPad` closes the card,
// and 14 here on top of it put the opened state 30 points off its own
// bottom edge. Horizontally flush with [_head], for the same reason.
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -520,66 +608,163 @@ class _TripBriefStripState extends State<TripBriefStrip> {
SizedBox(height: 14.h),
],
// ── Band 2: the journey, four centred figures on one row ──
// ── Band 2: the four facts, two by two ──
//
// `IntrinsicHeight` so the four columns share a height and their
// captions land on one baseline even when a value scales down.
// The band the fold exists for. The shift window above it is read
// once in the morning; these are read every time the rider plans the
// next hour, which is why the fold now opens by default and the
// chevron is for putting them away rather than for finding them.
_metrics(_left.cashToCollect),
],
),
);
}
/// ── The four facts, two by two ──
///
/// They were four centred columns on one line, inside the fold — a value over
/// a caption, ×4, packed across the card's width and reachable only by
/// opening something. Four across is also the shape that forces every figure
/// to a quarter of the width, which is why `≈3h 50m` and `26.9 km` were both
/// scaling themselves down to fit.
///
/// Two by two, on the face. Each cell is a quiet icon tile, the fact's name
/// above it in grey, and the figure under that in the card's own ink — so a
/// rider looking for the distance finds one of four places to look rather
/// than one of four columns to parse, and each figure gets half the width
/// instead of a quarter.
Widget _metrics(double cash) {
// ── Every figure here is about the work LEFT, not the slot as assigned ──
//
// These read `_trip` — the whole slot — while the headline eighty points
// above them counted down. So a rider with five collections to go was told
// `≈4h 15m · 29.0 km · 25 parcels`: the morning he started with, including
// everything already collected and gone to Deliveries.
//
// The card's subject is what is in front of him. See [Trip.outstanding]
// for where the line between the two screens is drawn.
final duration = roundTripDuration(_left.totalDuration);
final cells = <(IconData, String, String, bool)>[
(
LucideIcons.clock,
'Duration',
duration > Duration.zero
? '\u2248${formatTripDuration(duration)}'
: '\u2014',
duration <= Duration.zero,
),
(
// `ruler`, not `route`: the winding-path glyph is four strokes and two
// pins, and at this size it renders as a smudge. An icon the rider
// cannot resolve is decoration.
LucideIcons.ruler,
'Distance',
_left.routeMeters > 0
? RouteMetricsHelper.formatDistance(_left.routeMeters)
: '\u2014',
_left.routeMeters <= 0,
),
(
LucideIcons.package,
_left.totalParcels == 1 ? 'Parcel' : 'Parcels',
'${_left.totalParcels}',
_left.totalParcels == 0,
),
(
LucideIcons.banknote,
'Payment',
// The em dash, not a word: it is what every other figure in the app
// prints when there is nothing to print, and `None` set in figure type
// reads as data.
cash > 0 ? '\u20b9${_money(cash)}' : '\u2014',
cash <= 0,
),
];
return Column(
children: [
for (var row = 0; row < 2; row++) ...[
if (row > 0) SizedBox(height: 16.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _figure(
icon: LucideIcons.clock,
value: '≈${formatTripDuration(duration)}',
label: 'DURATION',
),
),
Expanded(
child: _figure(
// `straighten`, not `route`: the winding-path glyph is
// four strokes and two pins, and at this size it renders as
// a smudge. An icon the rider cannot resolve is decoration.
icon: LucideIcons.ruler,
value: RouteMetricsHelper.formatDistance(_trip.routeMeters),
label: 'DISTANCE',
),
),
Expanded(
child: _figure(
icon: LucideIcons.package,
value: '${_trip.totalParcels}',
label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS',
),
),
Expanded(
child: cash > 0
? _figure(
icon: LucideIcons.banknote,
value: '₹${_money(cash)}',
label: 'PAYMENT',
money: true,
)
// Holds its column so a cash-free trip keeps the row on
// four even columns instead of redistributing the space
// and shifting the other three figures sideways.
: _figure(
icon: LucideIcons.banknoteX,
// The em dash, not a word: '—' is what every other
// figure in the app prints when there is nothing to
// print, and 'None' set in figure type read as data
// — a rider scanning four numbers got three numbers
// and a word to parse.
value: '\u2014',
label: 'PAYMENT',
muted: true,
),
),
for (var col = 0; col < 2; col++) ...[
if (col > 0) SizedBox(width: 12.w),
Expanded(child: _metricCell(cells[row * 2 + col])),
],
],
),
),
],
),
],
);
}
/// One cell of the grid: a tile, a name, a figure.
Widget _metricCell((IconData, String, String, bool) cell) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// A quiet tile rather than a bare glyph. At two across there are only
// two of them per row, so they group the pair without becoming the
// loudest thing on the card the way four in a line did.
Container(
width: 34.w,
height: 34.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.tint(ColorConstants.slateText, 0.055),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
cell.$1,
size: 16.sp,
color: ColorConstants.secondaryText,
),
),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
cell.$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
height: 1.2,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
cell.$3,
maxLines: 1,
style: TextStyle(
fontSize: 15.sp,
height: 1.15,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
fontFeatures: const [FontFeature.tabularFigures()],
color: cell.$4
? ColorConstants.secondaryText
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
],
);
}
@@ -635,84 +820,6 @@ class _TripBriefStripState extends State<TripBriefStrip> {
);
}
/// One journey figure, centred on its column and standing on the page rather
/// than inside a box: icon, the number, a small-caps caption.
///
/// The icon is bare, with no tinted disc behind it. At four across, a disc per
/// figure put four filled circles in a row that read as the loudest thing in
/// the brief while carrying none of its information — and the caption under
/// each value already says what the icon says.
Widget _figure({
required IconData icon,
required String value,
required String label,
bool money = false,
bool muted = false,
}) {
// Only the money is coloured. The rest are facts about the journey, and
// tinting every category is the habit this screen has spent a long time
// unlearning.
final Color accent = money
? ColorConstants.moneyGreen
: muted
? ColorConstants.secondaryText
: ColorConstants.slateText;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 3.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 17.sp,
color: money
? ColorConstants.moneyGreen
: ColorConstants.secondaryText,
),
SizedBox(height: 7.h),
// Fixed height with a scale-down inside it, so a long value shrinks
// rather than making one of the four columns taller than the rest.
SizedBox(
height: 21.h,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Text(
value,
style: TextStyle(
fontSize: 16.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.05,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(height: 4.h),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 9.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
/// `1234.0` → `1,234`, `1234.5` → `1,234.5`. Mirrors the trim the stat card
/// used so the figure does not change shape as it moves into the strip.
static String _money(double v) {

View File

@@ -79,6 +79,10 @@ class TripCard extends StatelessWidget {
/// Opens a single stop's detail sheet (map + full order details).
final void Function(Map<String, dynamic> stop)? onStopTap;
/// One order's sheet, from a row inside an expanded place — see
/// [RouteTimeline.onOrderTap]. Falls back to [onStopTap] when unset.
final void Function(Map<String, dynamic> stop)? onOrderTap;
/// Rings the customer for this stop.
final void Function(Map<String, dynamic> stop)? onCallStop;
@@ -145,6 +149,7 @@ class TripCard extends StatelessWidget {
this.onSelectAll,
this.onUnrejectStop,
this.onStopTap,
this.onOrderTap,
this.onCallStop,
this.onViewRoute,
this.onContinueStop,
@@ -175,7 +180,7 @@ class TripCard extends StatelessWidget {
static bool _isChoosable(StopState state) =>
state == StopState.pending ||
(ServiceProfile.active.handsOffAtCollection &&
(state == StopState.accepted || state == StopState.active));
(state == StopState.accepted || state.isAtSource));
@override
Widget build(BuildContext context) {
@@ -247,6 +252,15 @@ class TripCard extends StatelessWidget {
/// four surfaces it replaced were the wrong price for the information.
Widget buildSummary() => TripBriefStrip(
trip: trip,
// What the four figures describe: the collections still owed, measured
// from where the rider is standing — the same origin the page used to
// build the trip in the first place, so the recount and the original are
// on the same footing. See [Trip.outstanding].
remaining: trip.outstanding(
collectedIds: collectedIds,
originLat: riderLat,
originLng: riderLng,
),
brief: brief ?? RouteBrief.empty,
percent: trip.completionPercent(
acceptedIds: acceptedIds,
@@ -287,7 +301,13 @@ class TripCard extends StatelessWidget {
int index,
})?
_nextGroup() {
for (final rung in const [StopState.active, StopState.accepted]) {
// Arrived outranks active: he is at that counter now, and a stop he has
// only been routed to can wait.
for (final rung in const [
StopState.arrived,
StopState.active,
StopState.accepted,
]) {
for (var i = 0; i < trip.stops.length; i++) {
if (_stateOf(trip.stops[i]) != rung) continue;
final stop = trip.stops[i];
@@ -351,6 +371,7 @@ class TripCard extends StatelessWidget {
onSelectAll: onSelectAll,
onToggleSelect: onToggleSelect,
onStopTap: onStopTap,
onOrderTap: onOrderTap ?? onStopTap,
// One journey action per place. Five Navigate buttons pointing at
// one counter was the largest single source of clutter on the old
// screen, and the rider had to work out whether they differed.
@@ -477,6 +498,10 @@ class TripCard extends StatelessWidget {
firstIndex: indices.first,
state: stops.isEmpty ? StopState.collected : _groupRung(stops),
stopStates: [for (final s in stops) _stateOf(s)],
// The whole bucket's states, so the card can tell an order that is
// still this counter's business from one already delivered — see
// [RouteGroup.groupSize].
allStopStates: [for (final s in all) _stateOf(s)],
travel: trip.travelTimeToStop(indices.first),
// ── Measured, or absent ──
//
@@ -530,7 +555,7 @@ class TripCard extends StatelessWidget {
// give. `trip_brief_layout_test.dart` sweeps for exactly this.
Flexible(
child: Text(
ServiceProfile.active.endsAtHub ? 'RETURN · HUB' : 'END · HOME',
ServiceProfile.active.endsAtHub ? 'RETURN · BASE' : 'END · HOME',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.eyebrow,
@@ -901,17 +926,17 @@ class TripCard extends StatelessWidget {
isStart
? (ServiceProfile.active.sourceIsKitchen
? 'START · KITCHEN'
: 'START · HUB')
: 'START · BASE')
// ── A milk run does not return anything ──
//
// He loads at the kitchen and his last address is a
// customer's door: there is no hub at the end of
// customer's door: there is no base at the end of
// his day and nothing to carry back to one. The
// logistics rider genuinely does return collected
// shipments to the depot, so the label follows the
// capability rather than the screen position.
: (ServiceProfile.active.endsAtHub
? 'RETURN · HUB'
? 'RETURN · BASE'
: 'END · HOME'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -1076,7 +1101,9 @@ class TripCard extends StatelessWidget {
/// what kept the row here in the first place.
static bool _showsOnHome(StopState state) =>
state == StopState.pending ||
state == StopState.active ||
// Arrived belongs here for the same reason active does, and it is the
// reason this reads as a predicate: see [StopStateX.isAtSource].
state.isAtSource ||
// Accepted stays only where the rider works that rung from this screen;
// on a parcel route it moves to Bookings the moment he takes it.
(state == StopState.accepted &&
@@ -1089,7 +1116,9 @@ class TripCard extends StatelessWidget {
/// heading cannot end up describing different rungs.
StopStatus _rungOf(StopState state) => switch (state) {
StopState.accepted => StopStatus.accepted,
StopState.active => StopStatus.arrived,
// Both: this answers *which rung is the stop on*, and it is the same for
// either — he is at the counter and the next thing he does is collect.
StopState.arrived || StopState.active => StopStatus.arrived,
_ => StopStatus.unknown,
};
@@ -1161,7 +1190,7 @@ class TripCard extends StatelessWidget {
// An **arrived** stop keeps a card-level rung, because by then the batch
// question is settled: he is at the counter, and the sheet it opens acts
// on every order from that counter anyway ([_advanceStopWithSheet]).
final advance = state == StopState.active
final advance = state.isAtSource
? (MilkRun.nextActionLabel(_rungOf(state)) == null
? null
: 'Mark as picked')
@@ -1281,7 +1310,7 @@ class TripCard extends StatelessWidget {
// shrinking.
Flexible(
child: Text(
'Trip complete · head back to the hub',
'Trip complete · head back to base',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(

View File

@@ -94,7 +94,7 @@ class TripEmptyState extends StatelessWidget {
illustrationSize: offline ? 180 : 208.w,
title: offline ? 'You are off duty' : 'Trip not assigned yet',
message: offline
? 'Go on duty to start receiving trips from your hub.'
? 'Go on duty to start receiving trips from your office.'
: null,
accent: offline ? cta : accent,
actionLabel: offline ? 'Go on duty' : null,

View File

@@ -534,7 +534,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
// A milk run finishes at the last door; there is no depot at
// the end of it and nothing to carry back to one.
? (ServiceProfile.active.endsAtHub
? 'Head back to the hub'
? 'Head back to base'
: 'Round complete')
: _footStatus(total: total, done: done),
maxLines: 1,
@@ -570,7 +570,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
// the same fiction the RETURN · HUB row was removed from Home for.
ServiceProfile.active.sourceIsKitchen
? '${formatTripDuration(roundTripDuration(left))} left'
: '${formatTripDuration(roundTripDuration(left))} left, then hub',
: '${formatTripDuration(roundTripDuration(left))} left, then base',
];
return parts.join(' · ');
}

File diff suppressed because it is too large Load Diff