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

@@ -83,6 +83,46 @@ String clockOf(DateTime t) {
return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}';
}
/// `Today`, `Yesterday`, or `17 Aug` — which day a record belongs to.
///
/// A clock alone is ambiguous the moment a rider opens a record that is not
/// today's, and Activity's own rule bans the all-caps date band that used to
/// answer it. This is the same fact as a caption on the record itself, where it
/// costs one line rather than a header per group.
String dayLabel(DateTime t, {DateTime? now}) {
final today = now ?? DateTime.now();
final day = DateTime(t.year, t.month, t.day);
final ref = DateTime(today.year, today.month, today.day);
final diff = ref.difference(day).inDays;
if (diff == 0) return 'Today';
if (diff == 1) return 'Yesterday';
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return '${t.day} ${months[t.month - 1]}';
}
/// `2:05` — the same clock as [clockOf] with the meridiem dropped.
///
/// For a strip of four times on one line, where the `AM`/`PM` is both the
/// widest part of the string and the part a rider can infer from the three
/// beside it.
String shortClockOf(DateTime t) {
final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12;
return '$hour12:${t.minute.toString().padLeft(2, '0')}';
}
/// A duration at a glance: `6m`, `1h 12m`.
///
/// Seconds are never shown — nothing on these screens is decided to the second,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

@@ -148,7 +148,7 @@ class _OrderStatusRowState extends State<OrderStatusRow> {
children: [
const MilerSheetHeader(
title: 'Reject booking',
subtitle: 'The hub re-routes it once it knows why.',
subtitle: 'Your office re-routes it once it knows why.',
),
const SizedBox(height: 14),
for (String reason in reasons)
@@ -208,7 +208,7 @@ class _OrderStatusRowState extends State<OrderStatusRow> {
children: [
const MilerSheetHeader(
title: 'Cancel this booking?',
subtitle: 'It returns to pending and the hub reassigns it.',
subtitle: 'It returns to pending and your office reassigns it.',
),
const SizedBox(height: 14),
CheckboxListTile(

View File

@@ -816,12 +816,23 @@ class _CallButton extends StatelessWidget {
/// the kitchen's. See [StopContact].
final String? semanticLabel;
const _CallButton({required this.phone, this.size, this.semanticLabel});
/// A disc rather than a rounded square. The sheets draw it beside a round
/// avatar, where a square tile reads as a control belonging to something
/// else; the cards keep the square, which matches the tiles around it.
final bool round;
const _CallButton({
required this.phone,
this.size,
this.semanticLabel,
this.round = false,
});
@override
Widget build(BuildContext context) {
return _SquareIconButton(
icon: LucideIcons.phone,
round: round,
size: size ?? math.max(52.0, 52.w),
// ── Green, and the same green everywhere a phone appears ──
//
@@ -833,9 +844,9 @@ class _CallButton extends StatelessWidget {
// app he already uses — it is what the glyph means before the palette
// gets a say — and "done" is carried by a tick and a word, not by a hue
// on a handset.
background: ColorConstants.acceptGreen.withValues(alpha: 0.12),
background: Narrative.emeraldWash,
border: Colors.transparent,
iconColor: ColorConstants.acceptGreen,
iconColor: Narrative.emerald,
semanticLabel: semanticLabel ?? 'Call this stop',
onTap: () async {
// A dialer that fails to open is silent by default: the rider is at
@@ -859,6 +870,7 @@ class _SquareIconButton extends StatelessWidget {
final Color iconColor;
final String semanticLabel;
final VoidCallback onTap;
final bool round;
const _SquareIconButton({
required this.icon,
@@ -868,25 +880,29 @@ class _SquareIconButton extends StatelessWidget {
required this.iconColor,
required this.semanticLabel,
required this.onTap,
this.round = false,
});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(
round ? DesignConstants.radiusFull : DesignConstants.radiusXl,
);
return Semantics(
button: true,
label: semanticLabel,
child: Material(
color: background,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: radius,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: radius,
border: Border.all(color: border, width: 1.5),
),
child: Icon(icon, color: iconColor, size: 22.sp),
@@ -1044,71 +1060,21 @@ class _QueueHeading extends StatelessWidget {
),
),
],
if (order != null) ...[
const Spacer(),
Flexible(child: _OrderProvenance(source: order!)),
],
// ── The provenance chip is gone ──
//
// `As assigned` / `Nearest first` sat at the head of the queue, in a
// tinted pill with a glyph, telling the rider where the *ordering* of
// his list came from. It is a fact about this app's sorting, not
// about his work: he cannot act on it, he cannot change it, and on
// the common day it said the same three words every time he opened
// the tab. What he needs from that row is which trip and how far
// through it he is, and both are already on it.
],
),
);
}
}
/// `Hub route` / `Nearest first` — where the queue's order came from.
///
/// Deliberately quiet. It is a provenance mark, not a warning: nearest-first
/// is a reasonable order when nobody has assigned one, and the rider does not
/// need alarming about it. What he needs is to not be told it is his route.
class _OrderProvenance extends StatelessWidget {
final RouteOrderSource source;
const _OrderProvenance({required this.source});
@override
Widget build(BuildContext context) {
final assigned = source.isAdmin;
final tint = assigned
? ColorConstants.acceptGreen
: ColorConstants.secondaryText;
return Tooltip(
message: source.explanation,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: tint.withValues(alpha: assigned ? 0.10 : 0.07),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
assigned ? LucideIcons.route : LucideIcons.navigation,
size: 11.sp,
color: tint,
),
SizedBox(width: 4.w),
Flexible(
child: Text(
source.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: tint,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
}
}
/// `Bag 1 · Joe` — the pairing, as one object.
///
/// ── Why they are one line and not two facts ──

View File

@@ -322,9 +322,10 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
if (_due <= 0) return _nothingToCollect();
return Scaffold(
// White page, tinted panels — the same inversion the verification screens
// it follows now use. See the note there.
backgroundColor: ColorConstants.pureSurface,
// Grey page, white bands — the same shape the verification screens this
// follows now take, so the money step reads as the next page of one flow
// rather than a different screen. See [MilerBand].
backgroundColor: ColorConstants.daylightSurface,
// Same bar as the verification screens this follows: taller, clear of
// the notch, title at a size that survives sunlight and arm's length.
appBar: milerPageBar(
@@ -336,18 +337,19 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 24.h),
// The bands carry the gutter. See [MilerBand].
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_amountCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_orderCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_methodCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_shortCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_custodyNote(),
],
),
@@ -366,13 +368,8 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
// door, and a number he can edit is a number he will be argued into
// changing.
Widget _amountCard() {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(18.w, 20.h, 18.w, 18.h),
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
return MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 20.h, MilerBand.pad, 18.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -822,13 +819,10 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
// once, plainly, at the moment he takes it.
Widget _custodyNote() {
final onBehalf = _shipper.isNotEmpty ? _shipper : 'the shipper';
return Container(
width: double.infinity,
padding: EdgeInsets.all(13.r),
decoration: BoxDecoration(
color: ColorConstants.tertiary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
return MilerBand(
color: ColorConstants.tint(ColorConstants.tertiary, 0.08),
rail: ColorConstants.tertiary,
padding: EdgeInsets.fromLTRB(MilerBand.pad, 13.h, MilerBand.pad, 13.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -837,7 +831,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
Expanded(
child: Text(
'This money belongs to $onBehalf, not to you and not to Miler. '
'Deposit it at the hub at the end of your shift.',
'Hand it in at the end of your shift.',
style: TextStyle(
fontSize: 12.sp,
height: 1.4,
@@ -1049,13 +1043,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
// ── Shared pieces ──────────────────────────────────────────────────────
Widget _card({required String title, required Widget child}) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -214,25 +214,142 @@ Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
/// (it left `Collected_By_Miler` some other way) and a lingering
/// `IDEMPOTENCY_IN_PROGRESS` after [MilerApi] has waited the write out.
/// Neither is answerable from the error itself.
/// Why the last [releaseForDelivery] said no, in the rider's words.
///
/// ── One message was covering four different problems ──
///
/// Every failure below returned a bare `false`, and the caller printed *"This
/// parcel isn't ready to go out yet. Check your connection and try again."* for
/// all of them. Three of the four have nothing to do with a connection, and
/// two of them cannot be fixed by trying again at all:
///
/// • **No consignment.** The pickup never converted, or its id was lost. The
/// rider can retry this until his battery dies.
/// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be
/// delivered by somebody else. There is nothing here for him to start.
/// • **Refused.** The server gave a reason and it was thrown away.
/// • **Unreachable.** The only one where "check your connection" is true.
///
/// Telling a rider to check a working connection, about a parcel that is not
/// his to deliver, is how a screen teaches him to ignore what it says. Null
/// after a success.
String? lastReleaseFailure;
/// Whether a "somebody else has this" hold is even possible on this rider's
/// line.
///
/// ── The state that cannot happen, refusing the rider anyway ──
///
/// `Inwarded_at_Hub`, `Tripsheet_Loaded` and `In_Transit` are the logistics
/// network's own rungs: a cross-city parcel is dropped at a collection point
/// and delivered by a different rider on a different day. That is a real hold
/// and it must keep blocking on the logistics line.
///
/// On a meal run **it cannot happen at all.** Every DailyGrubs order is
/// hyperlocal — collected at the kitchen and carried straight to the door, one
/// rider, one leg, no network in between. So one of those states coming back
/// for a meal stop is not news about the food; it is a bad read, and the two
/// ways it happens are both known: a consignment reference that resolved to
/// the wrong record, and a row whose state belongs to a different booking.
///
/// The rider was shown the hold anyway, mid-round, on a slider whose only job
/// is to set him off — and there was nothing he could do about it, because the
/// thing it described was not true of his stop.
///
/// So a hold is only believed on a line that has somewhere to hold things. On
/// every other line the state is treated as unreadable, which sends the
/// question to the server: it is the judge of its own consignment, and one
/// refusal carrying its own reason beats a client-side refusal inventing one.
bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub;
Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
lastReleaseFailure = null;
final consignmentId = await resolveConsignmentId(stop);
if (consignmentId.isEmpty) {
debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}');
lastReleaseFailure =
"This stop has no delivery reference yet, so it can't be started. "
'Ask your office to check it — sliding again will not help.';
return false;
}
// What the list row already told us, before spending a request on it.
var state = consignmentStateFromRaw(stop['consignmentstatus']);
if (state == ConsignmentState.unknown) {
// ── A row saying `Created` is the one row worth re-reading ──
//
// `Created` is the state a consignment holds for as long as it takes the
// pivot to route it, and `GET /miler/bookings` is a poll behind the rider by
// design. So a stop collected seconds ago can arrive on this tab carrying
// the state it had *before* `pickup-complete` finished with it, and every
// other state on the row is durable enough not to have that problem.
//
// Acting on it cost the rider the round: the guard below read it as hub
// custody and refused, and no amount of sliding cleared a row that only the
// next poll was going to correct. It is asked directly instead — one request,
// on the one state where the row is not evidence.
if (state == ConsignmentState.unknown ||
state == ConsignmentState.created) {
state = await ConsignmentGate.stateOf(consignmentId);
}
if (state.isDeliverable || state.isDelivered) return true;
if (!state.needsRelease && state != ConsignmentState.unknown) {
if (state.isDeliverable || state.isDelivered) {
// ── Released without a request, and the trace has to say so ──
//
// In compatibility mode the pivot has already released the consignment, so
// Start ride spends nothing here and the console was *already* Active
// before the rider slid. A silent `true` made that indistinguishable from
// a release this press actually made, which is the exact ambiguity the
// production trace has to resolve.
debugPrint(
'[TRACE][START-RIDE] consignment=$consignmentId '
'state="${state.name}" startDeliveryCalled=false '
'reason=already-released-by-pickup-complete (compatibility mode)',
);
return true;
}
// ── What may be *attempted*, as against what may be *claimed* ──
//
// This refused everything that was not `Collected_By_Miler` or unreadable,
// and `Created` fell into it wearing the hub's wording. Both halves were
// wrong for a parcel on the rider's own back.
//
// `Created` now goes to the server like `unknown` does, for the reason
// `unknown` does: the app is not the judge of a state it cannot fully
// account for, and one 400 carrying the backend's own sentence is worth more
// than a client-side refusal that invents one. A hyperlocal consignment the
// pivot has not finished releasing is let through; a hub-routed one is
// refused by the server, and the rider is told what the server said.
// A hold that cannot exist on this line is a bad read, not a fact about the
// stop. See [_handoffHoldIsPossible].
final heldElsewhere = state.awaitsHub && _handoffHoldIsPossible;
if (state.awaitsHub && !_handoffHoldIsPossible) {
debugPrint(
'[MILKRUN] $consignmentId read as ${state.name} on a line with no '
'handoff — treating as unreadable and asking the server',
);
}
if (!state.needsRelease &&
!state.awaitsHubInward &&
!(state.awaitsHub && !_handoffHoldIsPossible) &&
state != ConsignmentState.unknown) {
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
lastReleaseFailure = heldElsewhere
// The honest answer for a cross-city parcel: it is not his round, and
// saying so without naming a building he has never been to.
? "This one has already been handed on — it's not yours to deliver."
: 'This parcel cannot be started yet. Ask your office to check it.';
return false;
}
debugPrint(
'[TRACE][START-RIDE] consignment=$consignmentId state="${state.name}" '
'POST /miler/consignments/$consignmentId/start-delivery — calling',
);
final res = await MilerApi.startDelivery(consignmentId);
debugPrint(
'[TRACE][START-RIDE] consignment=$consignmentId startDeliveryCalled=true '
'-> ${res.status} ${res.code} ok=${res.ok} raw=${res.raw}',
);
if (res.ok) return true;
debugPrint(
@@ -242,7 +359,16 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
// Already on the road, or already handed over — either way this press is
// not what stands between the rider and the door.
final after = await ConsignmentGate.stateOf(consignmentId);
return after.isDeliverable || after.isDelivered;
if (after.isDeliverable || after.isDelivered) return true;
// `status: 0` is the request never leaving; anything else is the server
// answering, and its own words beat a guess about the network.
lastReleaseFailure = res.status == 0
? 'Could not reach your office. Check your connection and slide again.'
: (res.message.trim().isNotEmpty
? res.message.trim()
: 'Your office would not start this delivery.');
return false;
}
/// Brings this device into line with a delivery the server already has.
@@ -386,15 +512,42 @@ Future<Map<String, dynamic>?> _closeDelivery(
}
return {'outcome': 'completed', 'reconciled': true};
case DeliverGate.awaitingHub:
// The guard that must survive: a logistics consignment sitting at a
// hub genuinely is not this rider's to hand over.
debugPrint('[DELIVERY] $orderId is with the hub — refusing');
case DeliverGate.awaitingInward:
// Converted but never released and never inwarded. It is in his box,
// so the hub-hold sentence below would be false — but `deliver` will
// refuse it, so saying nothing and posting anyway would put a 400 on
// screen with no explanation attached.
debugPrint('[DELIVERY] $orderId is Created — not on the road yet');
if (context.mounted) {
AppFeedback.error(
context,
'This parcel is still with the hub. It can be delivered once the '
'hub releases it.',
"This parcel hasn't been released for delivery yet. Slide to "
'start the ride first, or ask your office to check it.',
);
}
return null;
case DeliverGate.awaitingHub:
// The guard that must survive: a logistics consignment inside the
// network genuinely is not this rider's to hand over.
//
// But only on a line that HAS a network. The same bad read that hit
// the Start-ride slider reaches this branch too, and refusing here
// is worse — the rider is at the door with the food in his hand.
// See [_handoffHoldIsPossible].
if (!_handoffHoldIsPossible) {
debugPrint(
'[DELIVERY] $orderId read as held on a line with no handoff — '
'ignoring the read and letting the server judge',
);
break;
}
debugPrint('[DELIVERY] $orderId is held elsewhere — refusing');
if (context.mounted) {
AppFeedback.error(
context,
"This one has already been handed on, so it can't be delivered "
'from here.',
);
}
return null;
@@ -441,9 +594,18 @@ Future<Map<String, dynamic>?> _closeDelivery(
}
}
if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) {
// Cancel is a *booking* route, so it is the one outcome that still works
// without a consignment.
if (outcome == DeliveryOutcome.delivered && consignmentId.isEmpty) {
// ── Only `deliver` needs a consignment ──
//
// This guard used to catch **skip** as well, and skip is the one thing a
// rider does when a delivery goes wrong — so the outcome he reaches for
// precisely when something is already wrong was the one refused with "this
// order's delivery reference is missing". Nothing he could do cleared it.
//
// Cancel was already exempt: it is a *booking* route. Skip now has one too
// (`POST /miler/bookings/:id/skip`, shipped 24 Aug), so both work without a
// consignment and only the hand-over — which genuinely keys on one — is
// held here.
//
// Reaching here means four sources came back empty, which is no longer a
// client-side gap: it means the pickup never converted this booking. The
@@ -492,7 +654,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
context,
collected
? "This order's delivery reference is missing, so it cannot be "
'completed from the app. Ask your hub to check it — '
'completed from the app. Ask your office to check it — '
'collecting it again will not help.'
: 'This order was never picked up on the system — mark it picked '
'up from Home first, then deliver.',
@@ -516,6 +678,38 @@ Future<Map<String, dynamic>?> _closeDelivery(
// No fix, and the outcome still has to be recordable. See above.
}
// ── The photograph leaves the phone ──
//
// It never could: `deliver` takes `photourl`, which wants a URL, and nothing
// on the contract accepted an upload — so proof of delivery lived in the
// app's own directory and died with the next reinstall, on the one record
// somebody asks about weeks later.
//
// `POST /miler/uploads/sign` (24 Aug) closes it: sign, PUT the bytes, send
// the public URL back on the delivery.
//
// **A failed upload never blocks a hand-over.** The parcel is in the
// customer's hands whatever the network did, and `deliver` accepts an empty
// `photourl`. The local copy is kept either way, so a failure costs the hub
// its copy and costs the rider nothing.
String proofUrl = '';
if (outcome == DeliveryOutcome.delivered && proofPath.isNotEmpty) {
final uploaded = await MilerApi.uploadProof(
File(proofPath),
purpose: MilerApi.proofDelivery,
consignmentId: consignmentId.isEmpty ? null : consignmentId,
);
if (uploaded != null) {
proofUrl = uploaded;
} else {
ApiConfig.logGap(
'deliver',
'the proof photo for $orderId could not be uploaded; the delivery is '
'being recorded without one and the copy stays on the device.',
);
}
}
bool ok = false;
try {
switch (outcome) {
@@ -523,6 +717,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
ok = await dc.updateDeliveredStatus(
pickupId: pickupIdInt,
consignmentId: consignmentId,
proofImage: proofUrl,
deliveredToName:
(stop['dropcustomer'] ??
stop['pickupcustomer'] ??
@@ -544,15 +739,55 @@ Future<Map<String, dynamic>?> _closeDelivery(
// fall back to the booking id and skip the wrong thing.
//
// The pickup-leg skip keeps that method; this is the delivery leg's.
final res = await MilerApi.skipConsignment(
consignmentId,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: riderFix?.latitude,
lon: riderFix?.longitude,
);
//
// ── Two routes, chosen by what the stop actually has ──
//
// With a consignment the skip belongs to it — that is the attempt
// counter the hub reads. Without one, the booking route is not a
// fallback but the *correct* call: a stop with no consignment has not
// been converted, so there is nothing on the delivery side to bump.
// Posting an empty id to `/consignments//skip` — which is what this
// did whenever the id was unreachable — 404s and tells the rider his
// own stop is broken.
final res = consignmentId.isEmpty
? await MilerApi.skipBooking(
stop['bookingid'] ?? stop['orderheaderid'] ?? orderId,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: riderFix?.latitude,
lon: riderFix?.longitude,
)
: await MilerApi.skipConsignment(
consignmentId,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: riderFix?.latitude,
lon: riderFix?.longitude,
);
ok = res.ok;
if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}');
// ── The attempt counter is the hub's, and it has a ceiling ──
//
// `skip` returns `attemptcount` and it is the source of truth: the
// consignment stays `Out_for_Delivery` and the parcel stays in the
// rider's hands, so nothing here may close the stop. At **3** the
// backend raises an Undeliverable exception for the hub, and there is
// no automated return or reassignment behind it — a human picks it up
// from there. Saying so is the difference between a rider trying a
// fourth time and a rider ringing the hub.
if (ok) {
final body = res.data;
final attempts = body is Map
? int.tryParse('${body['attemptcount'] ?? ''}') ?? 0
: 0;
if (attempts >= 3 && context.mounted) {
AppFeedback.info(
context,
'Third attempt on this stop — your office has been told and will '
'take it from here.',
);
}
}
case DeliveryOutcome.cancelled:
ok = await dc.updateCancelledStatus(
pickupId: pickupIdInt,
@@ -574,10 +809,26 @@ Future<Map<String, dynamic>?> _closeDelivery(
// show a finished screen while the office still had the order live. The
// rider keeps the stop and gets told why.
if (context.mounted) {
// ── Cancel is a booking route, and a collected parcel is past it ──
//
// `POST /miler/bookings/:id/cancel` is refused once the stop is picked
// up — by design: cancelling releases the booking for reassignment, and
// a parcel already in a rider's box cannot be handed to somebody else.
// The generic "could not record — try again" invited exactly the retry
// that can never work, so the one outcome with a *permanent* reason says
// it.
final collectedAlready =
outcome == DeliveryOutcome.cancelled &&
(stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg);
AppFeedback.error(
context,
dc.lastBlockedReason ??
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
collectedAlready
? 'You are already carrying this parcel, so it cannot be '
'cancelled from the app. Skip it to try again later, or ask '
'your office to cancel it.'
: dc.lastBlockedReason ??
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
);
}
return null;

View File

@@ -11,6 +11,8 @@ 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_app_bar.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// PROOF OF DELIVERY
@@ -42,6 +44,37 @@ import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
/// without a photo, and null when he backed out entirely — three outcomes the
/// caller can tell apart.
/// ─────────────────────────────────────────────────────────────────────────
/// How a stop ended, as this page reports it.
///
/// The page is reached by sliding **deliver**, so *delivered* is the answer it
/// exists to collect. The other two are the answers a rider only has once he is
/// standing at the door — and the door is the last place he should have to back
/// out of a screen to record one.
enum ProofOutcome {
/// Handed over. Carries the photograph, when there is one.
delivered,
/// Not this time — a return visit. The parcel stays on board.
skipped,
/// It is not going to happen. The order ends here.
cancelled,
}
/// What [DeliveryProofPage] pops: the outcome, and the proof if the outcome
/// was a hand-over.
class ProofResult {
final ProofOutcome outcome;
/// Where the photograph was saved. `''` means the rider completed without
/// one — a camera that would not open, a customer who objected — which is a
/// real answer and not a failure. Always `''` on the other two outcomes:
/// there is nothing to photograph about a delivery that did not happen.
final String proofPath;
const ProofResult(this.outcome, [this.proofPath = '']);
}
class DeliveryProofPage extends StatefulWidget {
/// Names the stop on the page, so the rider can see he is closing the right
/// one before he commits.
@@ -56,6 +89,55 @@ class DeliveryProofPage extends StatefulWidget {
required this.orderId,
});
/// The two answers a rider has when the hand-over did not happen.
///
/// ── Public, because this page is no longer the only door ──
///
/// It was private and reachable only from the link under **Mark as
/// delivered**, which put Skip and Cancelled two taps below a control the
/// rider has to slide *first*. A rider at a locked gate had to commit to
/// delivering before he could say he had not.
///
/// The map sheet offers it beside its own slider now, so the answer is
/// available at the moment the rider learns it. One sheet, two entry points:
/// the wording, the order and the accents cannot drift between them, and a
/// third outcome added here appears in both places at once.
static Future<ProofOutcome?> askOutcome(BuildContext context) {
return showMilerSheet<ProofOutcome>(
context,
builder: (ctx) => MilerSheetScaffold(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const MilerSheetHeader(
title: 'What happened at this stop?',
subtitle: 'This is recorded with the office straight away.',
),
SizedBox(height: 8.h),
MilerSheetChoiceRow(
label: 'Skip — keep it on board and try again later',
icon: LucideIcons.clock,
accent: ColorConstants.warning,
selected: false,
showRule: false,
onTap: () => Navigator.pop(ctx, ProofOutcome.skipped),
),
MilerSheetChoiceRow(
label: 'Cancelled — this order will not be delivered',
icon: LucideIcons.circleX,
accent: ColorConstants.errorRed,
selected: false,
showRule: true,
onTap: () => Navigator.pop(ctx, ProofOutcome.cancelled),
),
SizedBox(height: 4.h),
],
),
),
);
}
@override
State<DeliveryProofPage> createState() => _DeliveryProofPageState();
}
@@ -104,7 +186,27 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
// A failed copy must not block the hand-over — the delivery is the thing
// that matters, and the caller is told there is no proof rather than
// being handed a path that resolves to nothing.
Navigator.of(context).pop(saved ?? '');
Navigator.of(context).pop(ProofResult(ProofOutcome.delivered, saved ?? ''));
}
/// ── The two ways a stop ends without a hand-over ──
///
/// They used to live under the slide on the map sheet, which is one screen
/// too early: a rider slides *deliver* because he intends to deliver, and he
/// finds out he cannot at the door — on this page. Backing out of here to
/// reach them meant the only way to report a failure was to abandon the
/// screen that records outcomes.
///
/// Behind a link and on a sheet, not as two more buttons: both are
/// irreversible and one of them ends the order, and this page is held
/// one-handed at a doorstep. The happy path keeps the only filled control.
Future<void> _cannotDeliver() async {
if (_busy) return;
final outcome = await DeliveryProofPage.askOutcome(context);
if (!mounted || outcome == null) return;
// No photograph travels with either of these: there is nothing to
// photograph about a delivery that did not happen.
Navigator.of(context).pop(ProofResult(outcome));
}
@override
@@ -120,12 +222,15 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h),
// Bands, like the verification screen this page belongs
// beside: full-bleed white on the page's ground, the gap
// between them the only separator. See [MilerBand].
padding: EdgeInsets.only(bottom: 8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_who(),
SizedBox(height: 16.h),
_who(hasShot),
SizedBox(height: MilerBand.gap),
hasShot ? _preview() : _empty(),
],
),
@@ -138,10 +243,23 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
);
}
/// Which stop this closes. Named, because the one mistake this page can
/// enable is proving the wrong delivery.
Widget _who() => GlassCard(
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 14.h),
/// Which stop this closes, and the way to re-take the photo.
///
/// ── Retake came up here from the foot of the page ──
///
/// It sat at the bottom as a full-width outlined button beside **Mark as
/// delivered**, and that pairing was wrong twice over. It gave a *correction*
/// the same weight and the same row as the act that closes the order, on a
/// page whose one job is to close the order — so the rider's thumb had two
/// equal targets to choose between at the moment he least wants a choice.
/// And it put the control a screen away from the thing it controls: the photo
/// is at the top, and the button that replaces it was at the bottom.
///
/// It is a small round control on this band now, next to the person being
/// handed to. Reachable, obviously secondary, and beside the shot it retakes.
/// The bottom of the page is one button again.
Widget _who(bool hasShot) => MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 14.h, MilerBand.pad, 14.h),
child: Row(
children: [
Container(
@@ -149,7 +267,7 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen.withValues(alpha: 0.12),
color: ColorConstants.tint(ColorConstants.acceptGreen, 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
@@ -189,108 +307,164 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
],
),
),
// Only once there is a shot to replace. Before that, taking the first
// one is the *primary* thing to do and it has the big target in the
// middle of the page — a second camera control up here would be the
// same action offered twice.
if (hasShot) ...[
SizedBox(width: 10.w),
Semantics(
button: true,
label: 'Retake the photo',
excludeSemantics: true,
child: GestureDetector(
onTap: _busy ? null : _capture,
child: Container(
width: 46.w,
height: 46.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.tint(ColorConstants.slateText, 0.07),
shape: BoxShape.circle,
border: Border.all(color: ColorConstants.borderSubtle),
),
child: Icon(
LucideIcons.rotateCw,
size: 20.sp,
color: ColorConstants.slateText,
),
),
),
),
],
],
),
);
/// The shot, at the size a judgement can be made from.
Widget _preview() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: AspectRatio(
aspectRatio: 3 / 4,
child: Image.file(
File(_shotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: ColorConstants.cardSurface,
child: Center(
child: Icon(
LucideIcons.imageOff,
size: 32.sp,
color: ColorConstants.secondaryText,
Widget _preview() => MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 16.h, MilerBand.pad, 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: AspectRatio(
aspectRatio: 3 / 4,
child: Image.file(
File(_shotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: ColorConstants.cardSurface,
child: Center(
child: Icon(
LucideIcons.imageOff,
size: 32.sp,
color: ColorConstants.secondaryText,
),
),
),
),
),
),
),
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.circleCheck,
size: 16.sp,
color: ColorConstants.acceptGreen,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
'Photo captured — it stays on this record',
maxLines: 1,
overflow: TextOverflow.ellipsis,
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.circleCheck,
size: 16.sp,
color: ColorConstants.acceptGreen,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
'Photo captured — it stays on this record',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
],
),
);
/// Before the first shot, or after the rider dismissed the camera.
///
/// ── The whole band opens the camera ──
///
/// It was inert artwork, and the way back to the camera was an **Open
/// camera** button at the foot of the page. That button is gone — the foot of
/// the page is one big *Mark as delivered* now — so this had to become the
/// control rather than a picture of one, or a rider who dismissed the camera
/// would have no way back to it at all.
///
/// It is the better target anyway: a whole band in the middle of the screen,
/// where the eye already is, instead of a third of a row at the bottom.
Widget _empty() => Semantics(
button: !_cameraFailed,
label: _cameraFailed
? 'Camera unavailable. You can still complete this delivery.'
: 'Take a photo of the handover',
excludeSemantics: true,
child: GestureDetector(
onTap: _busy || _cameraFailed ? null : _capture,
child: MilerBand(
padding: EdgeInsets.fromLTRB(MilerBand.pad, 32.h, MilerBand.pad, 32.h),
child: Column(
children: [
Container(
width: 84.w,
height: 84.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.tint(ColorConstants.primary, 0.08),
shape: BoxShape.circle,
),
child: Icon(
_cameraFailed ? LucideIcons.imageOff : LucideIcons.camera,
size: 38.sp,
color: ColorConstants.primary,
),
),
SizedBox(height: 16.h),
Text(
_cameraFailed
? 'Camera unavailable'
: 'Take a photo of the handover',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
fontSize: 17.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
_cameraFailed
? 'You can still complete this delivery without a photo.'
: 'The parcel at the door, or with the person taking it.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5.sp,
height: 1.45,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
],
),
),
],
);
/// Before the first shot, or after the rider dismissed the camera.
Widget _empty() => GlassCard(
padding: EdgeInsets.fromLTRB(20.w, 32.h, 20.w, 32.h),
child: Column(
children: [
Container(
width: 72.w,
height: 72.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.primary.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
_cameraFailed ? LucideIcons.imageOff : LucideIcons.camera,
size: 32.sp,
color: ColorConstants.primary,
),
),
SizedBox(height: 16.h),
Text(
_cameraFailed ? 'Camera unavailable' : 'Take a photo of the handover',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
_cameraFailed
? 'You can still complete this delivery without a photo.'
: 'The parcel at the door, or with the person taking it.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5.sp,
height: 1.45,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
@@ -313,34 +487,56 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: MilerButton(
label: hasShot ? 'Retake' : 'Open camera',
icon: hasShot ? LucideIcons.rotateCw : LucideIcons.camera,
variant: MilerButtonVariant.outlined,
color: ColorConstants.slateText,
height: ButtonSizes.primary,
onPressed: _busy ? null : _capture,
),
// ── One button, and it is the size of the decision ──
//
// This row held two: an outlined **Retake** at one third and **Mark as
// delivered** at two thirds. Retake has gone up beside the photo it
// replaces (see [_who]), which leaves the act that closes the order
// alone at the foot of the page with the whole width to itself.
//
// 72pt rather than the app's ordinary 56. It is the last thing a rider
// does at a door, one-handed, often holding a parcel with the other —
// and it is the only control here, so nothing is crowded by giving it
// the room. The same height as the slide control on the map sheet, for
// the same reason: see [_SlideAction.height].
SizedBox(
height: 72.h,
child: MilerButton(
label: 'Mark as delivered',
icon: LucideIcons.circleCheck,
color: ColorConstants.acceptGreen,
height: 72.h,
loading: _busy,
onPressed: _busy ? null : _confirm,
),
),
// ── The way out, under the way forward ──
//
// Quiet, centred, and second: a rider who cannot hand over is the
// exception, and an exception must never be as easy to hit as the
// happy path. It is also the only route to Skip and Cancelled in the
// app, so it cannot be hidden either — a link under the button is
// where both of those are true at once.
SizedBox(height: 4.h),
TextButton(
onPressed: _busy ? null : _cannotDeliver,
style: TextButton.styleFrom(
minimumSize: Size(double.infinity, ButtonSizes.minTapTarget),
padding: EdgeInsets.zero,
),
child: Text(
"Couldn't hand it over?",
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
color: Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
SizedBox(width: 10.w),
Expanded(
flex: 2,
child: MilerButton(
label: 'Mark as delivered',
icon: LucideIcons.circleCheck,
color: ColorConstants.acceptGreen,
height: ButtonSizes.primary,
loading: _busy,
onPressed: _busy ? null : _confirm,
),
),
],
),
),
if (!hasShot) ...[
SizedBox(height: 8.h),
SizedBox(height: 4.h),
Text(
'No photo attached to this delivery',
textAlign: TextAlign.center,

View File

@@ -710,7 +710,7 @@ class _PickupsDoneState extends State<PickupsDone>
),
SizedBox(height: 8.h),
Text(
'Head back to the hub to drop the parcels and hand in any cash '
'Head back to base to drop the parcels and hand in any cash '
'you collected.',
textAlign: TextAlign.center,
style: TextStyle(
@@ -753,7 +753,7 @@ class _PickupsDoneState extends State<PickupsDone>
// A milk run ends at the last door, not at a depot — see
// [ServiceProfile.endsAtHub]. Capability, not screen position.
: (ServiceProfile.active.endsAtHub
? 'Return to hub'
? 'Return to base'
: 'Finish my round'),
icon: hasNext
? LucideIcons.arrowRight
@@ -980,7 +980,7 @@ class _PickupsDoneState extends State<PickupsDone>
label: hasNext
? 'Move to next stop'
: (ServiceProfile.active.endsAtHub
? 'Return to hub'
? 'Return to base'
: 'Finish my round'),
icon: hasNext
? LucideIcons.arrowRight

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,12 @@ library;
// what is removed here is this file's claim on them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show kDebugMode, setEquals;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@@ -30,6 +32,7 @@ import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/helpers/poll_policy.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/slide_action.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
@@ -1436,16 +1439,47 @@ class _MyPickupsState extends State<MyPickups>
/// has nothing to offer. **No feature flag is mirrored here**, because a
/// mirrored flag is a second source of truth that can be wrong.
///
/// The local sets are the fallback for a row that carries no consignment
/// status yet — an older deployment, or a stop collected seconds ago that
/// the queue has not caught up with.
/// ── The rider's press decides this, not the server's rung ──
///
/// This read the consignment state first and only fell back to the local
/// sets when the row carried none. That is the right instinct — a local flag
/// must never outrank a server fact — and it produced the wrong answer for
/// the case that is live on production today.
///
/// Two backend behaviours ship at once. With `MILER_COLLECTED_STATE_ENABLED`
/// **on**, `pickup-complete` stops at `Collected_By_Miler` and this method's
/// old reading is exactly right. With it **off** — compatibility mode, which
/// is the default and what the app meets in production — `pickup-complete`
/// releases the consignment itself, so a stop arrives on this tab already
/// `Out_for_Delivery`, `needsRelease` answers false, and the round starts
/// itself. The rider never sees PICKED and **Start delivery** never appears:
/// he collects at the kitchen and the app has him on the road before he has
/// left the counter.
///
/// So the two questions are separated, because they were never the same one:
///
/// ```
/// may this be delivered? → the consignment. ConsignmentGate, deliver
/// has he set off? → the rider. this method
/// ```
///
/// Nothing here contradicts the server. A stop the app holds at PICKED is
/// still `Out_for_Delivery` on the wire, `deliver` is still gated on the
/// consignment's own state, and pressing **Start delivery** on a consignment
/// that is already released costs no request at all — `releaseForDelivery`
/// returns true on sight of a deliverable state. What changes is only which
/// of the two facts the rider's card is showing him, and the answer is the
/// one he can act on.
///
/// The consignment state stays the fallback for a stop this device has no
/// record of — collected on another handset, or after a reinstall.
bool _needsRelease(Map<String, dynamic> stop) {
final state = consignmentStateFromRaw(stop['consignmentstatus']);
if (state != ConsignmentState.unknown) {
return state.needsRelease;
}
final id = MilkRun.idOf(stop);
return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id);
// He pressed it. Nothing outranks that.
if (_outForDeliveryIds.contains(id)) return false;
// He collected it here and has not pressed it.
if (_collectedIds.contains(id)) return true;
return consignmentStateFromRaw(stop['consignmentstatus']).needsRelease;
}
/// True while the round is being marked started, so the bar cannot be
@@ -1536,10 +1570,19 @@ class _MyPickupsState extends State<MyPickups>
final held = _unreleasedFailures;
if (n == 0) {
// One stop, one reason: say it. `releaseForDelivery` knows whether the
// parcel is at a hub, missing its reference, refused, or genuinely
// unreachable — see [lastReleaseFailure] — and "check your connection"
// is true for only the last of those.
//
// A whole round that failed keeps the general wording: with several
// stops there is no single reason to give, and the per-stop detail is in
// the log.
AppFeedback.errorGlobal(
held == 1
? 'Could not start that delivery. Check your connection and try '
'again.'
? (lastReleaseFailure ??
'Could not start that delivery. Check your connection and '
'try again.')
: 'Could not start the round. Check your connection and try again.',
);
return;
@@ -1751,23 +1794,46 @@ class _MyPickupsState extends State<MyPickups>
);
}
// ── Opening a stop starts its leg, if the bar has not already ──
// ── Opening a stop does NOT start its leg ──
//
// START DELIVERY is the normal way in and releases the whole load at once.
// This is the other way in: a rider who taps a card without pressing the
// bar is just as much on his way, and the stop has to be released before he
// gets there — `deliver` refuses any consignment that is not
// `Out_for_Delivery`, so opening one unreleased would walk him to a door
// and fail him at it.
// This used to call `startRound([item])` here — the same server release the
// Start-ride slider makes — on the reasoning that a rider who taps a card
// without pressing the bar is just as much on his way, and that `deliver`
// refuses anything that is not `Out_for_Delivery`, so an unreleased stop
// would walk him to a door and fail him at it.
//
// The same call the bar makes, for one order. [startRound] writes the local
// marker only after the server agrees, so a failure here leaves the row
// reading PICKED rather than lying about a round the hub has not started.
if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) &&
!_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) {
await startRound([item]);
if (!mounted) return;
}
// ── Why that is the production lifecycle bug, one tap later ──
//
// [MilkRun.navigatesToCustomer] is true the moment a stop is in
// `_collectedIds`, which is the moment **Picked** succeeds. So the sequence
// that matters is:
//
// ```
// Home slide Picked → pickup-complete → Collected_By_Miler
// Work tap the card → start-delivery → Out_for_Delivery ← here
// Map slide Start ride (already released)
// ```
//
// Tapping a card is not a statement that the rider has set off. It is how
// he reads an address, checks a phone number, or looks at the route — and
// it fired the one call that is supposed to be the slider's alone, so the
// console went **Active** while the load was still on the counter and the
// rider had not slid anything.
//
// It is invisible today only because `MILER_COLLECTED_STATE_ENABLED` is
// off: the pivot has already released the consignment, so
// `releaseForDelivery` sees a deliverable state and returns true without
// spending a request. **The flag flip is what arms this.** Flipping it
// without this change moves the early-Active bug rather than fixing it.
//
// The door is still covered, and by the branch written for it: the
// `DeliverGate.needsRelease` case in `_closeDelivery` releases a stop the
// rider never started, at the moment he actually hands over, then re-reads
// the state before posting. That is the safety net this pre-release was
// duplicating — one tap too early and on the wrong side of the boundary.
//
// See `MilerLifecycle`, and the regression tests in
// `test/picked_does_not_release_test.dart`.
await openScreen(
context,
@@ -1962,10 +2028,25 @@ class _MyPickupsState extends State<MyPickups>
// `where` that used to sit here, re-admitting rows by raw status, is gone
// with it: it was the hole an accepted-but-uncollected booking came
// through, and a second expression of a fact that now has one.
// ── What the rider has already written off today ──
//
// Read from the two stores that record a mutation *after* it came back
// OK, so this is authoritative and not a display flag: `completed` holds
// delivered and cancelled, `skipped` holds a parked attempt. Both are
// day-stamped and scoped to this rider, tenant and line, and both survive
// a cold start — which is what stops `/miler/bookings` re-admitting a
// skipped stop on the next poll while the consignment is still
// `Out_for_Delivery`. See [WorkBoundary.isClosed].
final closedIds = <String>{
...await getCompletedOrderIds(),
...await getSkippedOrderIds(),
};
var dedupedList = WorkBoundary.deliveryQueue(
items.whereType<Map<String, dynamic>>(),
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
);
// Drop stops the rider just finished. The backend may still return them
@@ -1996,17 +2077,17 @@ class _MyPickupsState extends State<MyPickups>
'${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}',
);
// Re-apply locally-skipped orders so a skip survives polling even when the
// backend hasn't recorded it yet (e.g. mock/offline data). Without this,
// the 3s poll would overwrite the skip with the raw API status. Applied
// before the filter below, because the filter now acts on it.
if (_skippedOrdersCache.isNotEmpty) {
for (final o in sortedList) {
if (_skippedOrdersCache.containsKey(_getOrderKey(o))) {
o['orderstatus'] = 'skipped';
}
}
}
// ── The poll-time re-stamp is gone ──
//
// This used to walk the list writing `orderstatus = 'skipped'` onto every
// row in the in-memory skip cache, so that the 3s poll could not revert a
// skip the backend had not recorded. That was a display flag papering
// over dual ownership: the row was still in the queue, still owned by
// this tab, and merely wearing a different word.
//
// A skipped order no longer reaches this point — `closedIds` drops it at
// the boundary, above both screens — so there is nothing left to stamp
// and no second opinion to keep in sync. See [WorkBoundary.isClosed].
// Build the top list = the stops he is actually working.
// - ACTIVE stops go to the banner, not the list.
@@ -2895,14 +2976,20 @@ class _MyPickupsState extends State<MyPickups>
}
},
child: Scaffold(
// The same white as Home, Activity and Account. This was `surface`
// (#FCF9F8, a warm off-white); the difference is invisible under a full
// list and obvious the moment the list is short, so the tab appeared to
// change colour with the rider's workload.
// The brand shows through the sheet's two top corners — see
// [MilerSheet].
// ── The corner has to have something behind it ──
//
// The sheet's two top corners were clipping onto a ground painted in
// the same canvas the sheet itself uses, so the curve revealed the
// page's own colour and was invisible: this tab read as a hard maroon
// line above the queue while Home and Activity carried the shape. The
// brand goes behind the sheet, which is what the other tabs do and
// what makes the curve a curve. The page's own colour is the sheet's
// (`MilerSurface.canvas`, one line below), not this.
backgroundColor: ColorConstants.primary,
appBar: MilerAppBar(
// The bar's curve opens onto the sheet's canvas, not the app's
// paper. See [milerBrandSurface].
pageColor: MilerSurface.canvas,
// ── The tab and the page it opens must agree ──
//
// This was the literal 'My Bookings', while the nav tab underneath it
@@ -2936,322 +3023,342 @@ class _MyPickupsState extends State<MyPickups>
// [MilerSurface].
body: MilerSheet(
color: MilerSurface.canvas,
child: Stack(
children: [
// Three states, cross-faded: still asking, nothing to do, and the
// queue itself. The middle one used to cover the first, and the
// hand-off between any two of them used to be a single frame.
SmoothSwap(
child: _picked.isEmpty && !_firstLoadDone
? const SkeletonList(key: ValueKey('bookings-loading'))
: _picked.isEmpty && _fetchFailed
? ErrorRetry(
key: const ValueKey('bookings-error'),
onRetry: () => _fetchPicked(force: true),
)
: _picked.isEmpty
? SingleChildScrollView(
key: const ValueKey('bookings-empty'),
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
// ── The artwork is the whole empty state ──
//
// This was a [MilerEmptyState] drawing a headline and a
// body line above the illustration — and the
// illustration has both baked into it, in the same
// words: "You're all caught up", then the line about
// new jobs appearing automatically. Rendered together
// the screen said each sentence twice, which reads as a
// rendering fault rather than as one message. The panel
// is the picture now, at a size worth looking at, and
// it keeps its own copy.
//
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
// folds away entirely in release, so nothing ships
// underneath the art.
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
),
child: Image.asset(
'assets/images/caught_up.png',
width: 330.w,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
LucideIcons.truck,
size: 64.sp,
color: ColorConstants.borderStrong,
// ── The sheet has to fill the body, not the queue ──
//
// A `Stack` hands its children loose constraints and then shrinks to
// the tallest of them, so on a short queue — or the empty state —
// the sheet ended where the content did and the rest of the body was
// whatever the Scaffold was painted with. That was invisible while
// the ground was the same canvas as the sheet; the moment the brand
// went behind it (for the corners) it became a red band across the
// bottom of the tab.
child: SizedBox.expand(
child: Stack(
children: [
// Three states, cross-faded: still asking, nothing to do, and the
// queue itself. The middle one used to cover the first, and the
// hand-off between any two of them used to be a single frame.
SmoothSwap(
child: _picked.isEmpty && !_firstLoadDone
? const SkeletonList(key: ValueKey('bookings-loading'))
: _picked.isEmpty && _fetchFailed
? ErrorRetry(
key: const ValueKey('bookings-error'),
onRetry: () => _fetchPicked(force: true),
)
: _picked.isEmpty
? SingleChildScrollView(
key: const ValueKey('bookings-empty'),
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
// ── The artwork is the whole empty state ──
//
// This was a [MilerEmptyState] drawing a headline and a
// body line above the illustration — and the
// illustration has both baked into it, in the same
// words: "You're all caught up", then the line about
// new jobs appearing automatically. Rendered together
// the screen said each sentence twice, which reads as a
// rendering fault rather than as one message. The panel
// is the picture now, at a size worth looking at, and
// it keeps its own copy.
//
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
// folds away entirely in release, so nothing ships
// underneath the art.
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
),
child: Image.asset(
'assets/images/caught_up.png',
width: 330.w,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
LucideIcons.truck,
size: 64.sp,
color: ColorConstants.borderStrong,
),
),
),
),
_fetchDiagLine(),
],
_fetchDiagLine(),
],
),
),
),
),
)
: Column(
key: const ValueKey('bookings-list'),
children: [
// No trip tabs here. Everything on this screen is already
// accepted, so the only live question is which stop is
// next — and that has one answer across the whole day.
// See [_visibleStops].
_buildTripRail(),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchPicked,
color: ColorConstants.primary,
// ── An empty ROW list, not an empty stop list ──
//
// This asked `_visibleStops.isEmpty`, but the rows
// are built from a filtered subset of it. When the
// stops were all finished-but-not-yet-cleared, the
// page took the populated branch and rendered a
// `ListView` with zero children: a blank screen
// under the rail, with no explanation and nothing
// to pull on. Ask the list that is actually drawn.
child: _queueRows().isEmpty
? _buildTripTabEmptyState()
: Builder(
builder: (context) {
final rows = _queueRows();
return ListView.builder(
padding: EdgeInsets.only(
top: 2.h,
// Room for the live strip only when
// one is actually drawn — see
// [_liveOnThisScreen].
// ── Room for whichever bar is
// drawn ──
//
// A flat 80 was the live strip's
// height, and the round bar is
// taller: a caption, a primary
// button and the nav-bar inset
// under it. The last card sat
// behind it with its controls
// unreachable. Measured from the
// pieces rather than guessed, so it
// cannot drift when either grows.
// ── One measured floor ──
//
// The unreleased branch was built
// from its bar's own pieces; the
// live branch was a flat `80.h`
// that forgot the device inset
// entirely, so on a gesture-nav
// phone the last row of the run sat
// behind the banner *and* behind
// the navigation. Both surfaces now
// state their own height and the
// floor reserves whichever one is
// actually showing, plus the inset.
bottom: _bottomReservation(context),
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
if (row.isHeading) {
return _QueueHeading(
label: row.heading!,
count: row.count,
// On the first heading only:
// it describes the whole
// queue, not the section.
order: index == 0
? _orderSource
: null,
);
}
final item = row.stop!;
final orderId =
(item['orderid'] ?? '')
.toString();
// Live metres + speed drive the
// distance / ETA on every density;
// RouteMetricsHelper picks the unit
// and falls back to an urban average
// when the GPS reading is unusable.
final distanceMeters =
RouteMetricsHelper.metersToStop(
item,
riderLat: _currentLocation
?.latitude,
riderLng: _currentLocation
?.longitude,
// The leg decides the end:
// a delivery row measures
// to the DOOR. It measured
// to the kitchen, so the
// figure grew as the rider
// approached the customer.
toDrop: MilkRun.workingKind(
item,
collectedIds: _collectedIds,
).isDelivery,
)
: Column(
key: const ValueKey('bookings-list'),
children: [
// No trip tabs here. Everything on this screen is already
// accepted, so the only live question is which stop is
// next — and that has one answer across the whole day.
// See [_visibleStops].
_buildTripRail(),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchPicked,
color: ColorConstants.primary,
// ── An empty ROW list, not an empty stop list ──
//
// This asked `_visibleStops.isEmpty`, but the rows
// are built from a filtered subset of it. When the
// stops were all finished-but-not-yet-cleared, the
// page took the populated branch and rendered a
// `ListView` with zero children: a blank screen
// under the rail, with no explanation and nothing
// to pull on. Ask the list that is actually drawn.
child: _queueRows().isEmpty
? _buildTripTabEmptyState()
: Builder(
builder: (context) {
final rows = _queueRows();
return ListView.builder(
padding: EdgeInsets.only(
top: 2.h,
// Room for the live strip only when
// one is actually drawn — see
// [_liveOnThisScreen].
// ── Room for whichever bar is
// drawn ──
//
// A flat 80 was the live strip's
// height, and the round bar is
// taller: a caption, a primary
// button and the nav-bar inset
// under it. The last card sat
// behind it with its controls
// unreachable. Measured from the
// pieces rather than guessed, so it
// cannot drift when either grows.
// ── One measured floor ──
//
// The unreleased branch was built
// from its bar's own pieces; the
// live branch was a flat `80.h`
// that forgot the device inset
// entirely, so on a gesture-nav
// phone the last row of the run sat
// behind the banner *and* behind
// the navigation. Both surfaces now
// state their own height and the
// floor reserves whichever one is
// actually showing, plus the inset.
bottom: _bottomReservation(
context,
),
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
if (row.isHeading) {
return _QueueHeading(
label: row.heading!,
count: row.count,
// On the first heading only:
// it describes the whole
// queue, not the section.
order: index == 0
? _orderSource
: null,
);
}
void open() => StopDetailSheet.show(
context,
stop: item,
stopNumber: row.displayStep,
// The pairing recorded at the
// counter — same store as the
// card, so the two agree.
bag: row.bag,
// The carried set — what turns
// a raw pickup row into the
// delivery leg the sheet must
// describe.
collectedIds: _collectedIds,
riderLat:
_currentLocation?.latitude,
riderLng:
_currentLocation?.longitude,
);
final item = row.stop!;
final orderId =
(item['orderid'] ?? '')
.toString();
// Live metres + speed drive the
// distance / ETA on every density;
// RouteMetricsHelper picks the unit
// and falls back to an urban average
// when the GPS reading is unusable.
final distanceMeters =
RouteMetricsHelper.metersToStop(
item,
riderLat: _currentLocation
?.latitude,
riderLng: _currentLocation
?.longitude,
// The leg decides the end:
// a delivery row measures
// to the DOOR. It measured
// to the kitchen, so the
// figure grew as the rider
// approached the customer.
toDrop: MilkRun.workingKind(
item,
collectedIds:
_collectedIds,
).isDelivery,
);
// ── One card shape per density ──
//
// Not one card dimmed four ways. See
// [QueueDensity] for why the live stop
// is physically bigger rather than
// merely brighter.
final Widget child = switch (row
.density) {
QueueDensity.now => PickupCard(
key: ValueKey(
'pickup_$orderId',
),
item: item,
displayStep: row.displayStep,
distanceMeters: distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
void
open() => StopDetailSheet.show(
context,
stop: item,
stopNumber: row.displayStep,
// The pairing recorded at the
// counter — same store as the
// card, so the two agree.
bag: row.bag,
// The carried set — what turns
// a raw pickup row into the
// delivery leg the sheet must
// describe.
collectedIds: _collectedIds,
outForDeliveryIds:
_outForDeliveryIds,
),
QueueDensity.next => _NextCard(
item: item,
bag: row.bag,
distanceMeters: distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
onOpen: open,
),
QueueDensity.later => _LaterRow(
item: item,
bag: row.bag,
distanceMeters: distanceMeters,
onOpen: open,
),
QueueDensity.skipped =>
_SkippedRow(
riderLat:
_currentLocation?.latitude,
riderLng:
_currentLocation?.longitude,
);
// ── One card shape per density ──
//
// Not one card dimmed four ways. See
// [QueueDensity] for why the live stop
// is physically bigger rather than
// merely brighter.
final Widget child = switch (row
.density) {
QueueDensity.now => PickupCard(
key: ValueKey(
'pickup_$orderId',
),
item: item,
displayStep: row.displayStep,
distanceMeters:
distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
bag: row.bag,
collectedIds: _collectedIds,
outForDeliveryIds:
_outForDeliveryIds,
),
QueueDensity.next => _NextCard(
item: item,
bag: row.bag,
reason: row.reason,
distanceMeters:
distanceMeters,
riderSpeedMps:
_currentLocation?.speed,
onOpen: open,
onResume: () =>
_resumeSkipped(item),
),
};
QueueDensity.later => _LaterRow(
item: item,
bag: row.bag,
distanceMeters:
distanceMeters,
onOpen: open,
),
QueueDensity.skipped =>
_SkippedRow(
item: item,
bag: row.bag,
reason: row.reason,
onOpen: open,
onResume: () =>
_resumeSkipped(item),
),
};
return Reveal(
key: ValueKey('reveal_$orderId'),
delay: staggerDelay(index),
child: child,
);
},
);
},
),
return Reveal(
key: ValueKey(
'reveal_$orderId',
),
delay: staggerDelay(index),
child: child,
);
},
);
},
),
),
),
),
],
),
),
// The live strip, shared with Home. It places itself clear of the
// floating nav bar, so it is pinned flush here and left to it.
//
// Only for a stop this screen can actually work. On a kitchen line
// a collection is Home's, in bulk, so a strip here offering to
// "continue" one was an invitation into a flow the rider is not
// supposed to be in — and the way most riders found it. See
// [_liveOnThisScreen].
// ── The hinge of the day, at the foot of the tab that holds the
// load ──
//
// It shares this slot with the live strip and yields to it: once a
// stop is running, "continue that stop" is the only thing the rider
// wants from the bottom of this screen, and a second bar under his
// thumb offering to start something else is how the wrong one gets
// pressed. See [startRound].
// ── One contextual slot, and the workflow advances through it ──
//
// These were two `if`s over the same corner. Logically exclusive
// already — a live stop suppresses the start bar, because a
// second bar under the rider's thumb offering to start something
// else is how the wrong one gets pressed — but they *swapped* by
// one disappearing and the other appearing in the same frame,
// which reads as the screen glitching rather than as **Start
// delivery → Continue this stop**.
//
// One switcher, one child at a time, on the app's own state
// duration. Keyed by which surface it is rather than by its
// contents, so a poll that returns the same live stop does not
// replay the transition.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSwitcher(
duration: DesignConstants.motionState,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: _liveOnThisScreen.isNotEmpty
? ActivePickupBanner(
key: const ValueKey('live'),
activePickups: _liveOnThisScreen,
onTap: (pickup) async {
await startPickup(pickup);
// Refresh after returning from map screen
if (mounted) {
await Future.delayed(
const Duration(milliseconds: 500),
);
_fetchPicked();
}
},
)
: _unreleased.isNotEmpty
? StartRoundBar(
key: const ValueKey('start-round'),
count: _unreleased.length,
busy: _starting,
onPressed: _startWholeRound,
)
: const SizedBox.shrink(key: ValueKey('none')),
],
),
),
),
],
// The live strip, shared with Home. It places itself clear of the
// floating nav bar, so it is pinned flush here and left to it.
//
// Only for a stop this screen can actually work. On a kitchen line
// a collection is Home's, in bulk, so a strip here offering to
// "continue" one was an invitation into a flow the rider is not
// supposed to be in — and the way most riders found it. See
// [_liveOnThisScreen].
// ── The hinge of the day, at the foot of the tab that holds the
// load ──
//
// It shares this slot with the live strip and yields to it: once a
// stop is running, "continue that stop" is the only thing the rider
// wants from the bottom of this screen, and a second bar under his
// thumb offering to start something else is how the wrong one gets
// pressed. See [startRound].
// ── One contextual slot, and the workflow advances through it ──
//
// These were two `if`s over the same corner. Logically exclusive
// already — a live stop suppresses the start bar, because a
// second bar under the rider's thumb offering to start something
// else is how the wrong one gets pressed — but they *swapped* by
// one disappearing and the other appearing in the same frame,
// which reads as the screen glitching rather than as **Start
// delivery → Continue this stop**.
//
// One switcher, one child at a time, on the app's own state
// duration. Keyed by which surface it is rather than by its
// contents, so a poll that returns the same live stop does not
// replay the transition.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSwitcher(
duration: DesignConstants.motionState,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: _liveOnThisScreen.isNotEmpty
? ActivePickupBanner(
key: const ValueKey('live'),
activePickups: _liveOnThisScreen,
onTap: (pickup) async {
await startPickup(pickup);
// Refresh after returning from map screen
if (mounted) {
await Future.delayed(
const Duration(milliseconds: 500),
);
_fetchPicked();
}
},
)
: _unreleased.isNotEmpty
? StartRoundBar(
key: const ValueKey('start-round'),
count: _unreleased.length,
busy: _starting,
onPressed: _startWholeRound,
)
: const SizedBox.shrink(key: ValueKey('none')),
),
),
],
),
),
),
),

View File

@@ -683,7 +683,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
if (payRes?['status'] != true && mounted) {
AppFeedback.error(
context,
"Payment recorded on your phone but not with the hub — tell them "
"Payment recorded on your phone but not with your office — tell them "
"you collected ₹${_paidAmount.toStringAsFixed(0)}",
);
}
@@ -1285,19 +1285,27 @@ class _StopBrief extends StatelessWidget {
children: [
Row(
children: [
// ── The avatar is a disc, and it is neutral ──
//
// A tinted rounded square in the leg's accent put a third coloured
// object on a sheet that already has a state pill and a coloured
// rail, and the colour said nothing the two of them had not
// already said. Initials are an identity, not a state: they take
// the app's quiet inset tone, and the round shape is what tells
// the eye it stands for a person.
Container(
width: 40.w,
height: 40.w,
width: 42.w,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kind.accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
decoration: const BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Text(
initials,
style: TextStyle(
color: kind.accent,
fontSize: 13.5.sp,
color: ColorConstants.secondaryText,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
@@ -1355,8 +1363,12 @@ class _StopBrief extends StatelessWidget {
// Aligned to the avatar's column so the two rows read as one block
// rather than as two things that happen to be near each other.
SizedBox(
width: 40.w,
child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent),
width: 42.w,
child: Icon(
LucideIcons.mapPin,
size: 20.sp,
color: ColorConstants.primary,
),
),
SizedBox(width: 12.w),
Expanded(
@@ -1364,12 +1376,20 @@ class _StopBrief extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
// ── The destination label is brand ──
//
// It took the leg's accent — blue on a delivery — which is
// a *classification* colour, and this line is not
// classifying anything: the rider is already on the screen
// for this stop. What it is doing is pointing at where he
// has to go, and pointing is what the brand does on this
// sheet (the rail, the pin beside this label).
kind.locationLabel,
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: kind.accent,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),

View File

@@ -209,7 +209,7 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
_quoteError = quote.found
? null
: 'No price is configured for this weight and route. '
'Call the hub before collecting.';
'Call your office before collecting.';
});
}
@@ -297,14 +297,22 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
// ── The bands carry the gutter, the page does not ──
//
// Every child below is full-bleed and pads its own content by
// [_bandPad]. The page used to hold a 16pt margin and give each
// card a border and a radius on top of it, which spent 32 of a
// 390pt phone's width on two strips of grey either side of a
// form whose fields are addresses. The ground showing between
// two bands separates them just as well and costs nothing.
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_journeyCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_parcelCard(),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
_quotePanel(),
],
),
@@ -360,13 +368,7 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
/// and it is the shape because it is read in one glance.
/// ─────────────────────────────────────────────────────────────────────
Widget _journeyCard() {
return Container(
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
),
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -508,14 +510,14 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
/// One surface rather than a heading and three loose blocks, for the same
/// reason as the journey above: these three answers are one description of
/// one parcel, and the price is computed from all three together.
/// The inset a full-bleed band keeps around its content. The app's own, so
/// this page and the verification screen that hands off to it line up at the
/// same left margin. See [MilerBand].
double get _bandPad => MilerBand.pad;
Widget _parcelCard() {
return Container(
padding: EdgeInsets.fromLTRB(14.w, 15.h, 14.w, 16.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
),
return MilerBand(
padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -588,21 +590,14 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
final quote = _quote;
final priced = quote != null && !_quoting && _quoteError == null;
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h),
decoration: BoxDecoration(
color: priced
? ColorConstants.acceptGreen.withValues(alpha: 0.06)
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: priced
? ColorConstants.acceptGreen.withValues(alpha: 0.28)
: ColorConstants.borderSubtle,
width: 1.2,
),
),
// A priced band goes pale green and takes the rail; an unpriced one stays
// white. Both fills are opaque colours rather than the accent laid over the
// page — see [ColorConstants.tint].
return MilerBand(
color: priced
? ColorConstants.tint(ColorConstants.acceptGreen, 0.06)
: ColorConstants.pureSurface,
rail: priced ? ColorConstants.acceptGreen : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -207,25 +207,29 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
// Full-bleed bands, like the verification screen this page is the
// end of. The section headers moved inside the band they head — a
// heading on the page and a card under it puts two left margins on
// one screen, which is the thing bands exist to stop. See
// [MilerBand].
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_routeCard(),
SizedBox(height: 14.h),
const SectionHeader(title: 'Shipment'),
SizedBox(height: 8.h),
SizedBox(height: MilerBand.gap),
_shipmentCard(),
SizedBox(height: 14.h),
const SectionHeader(title: 'Payment'),
SizedBox(height: 8.h),
SizedBox(height: MilerBand.gap),
_paymentCard(),
if (_error != null) ...[
SizedBox(height: 14.h),
InfoBanner(
icon: LucideIcons.circleAlert,
text: _error!,
color: ColorConstants.errorRed,
SizedBox(height: MilerBand.gap),
Padding(
padding: EdgeInsets.symmetric(horizontal: MilerBand.pad),
child: InfoBanner(
icon: LucideIcons.circleAlert,
text: _error!,
color: ColorConstants.errorRed,
),
),
],
],
@@ -239,7 +243,7 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
/// From → to, drawn as a journey rather than two labelled fields.
Widget _routeCard() {
return AppCard(
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -345,9 +349,12 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
final weight = widget.shipment['weight'];
final km = widget.shipment['approxkm'];
return AppCard(
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(title: 'Shipment'),
SizedBox(height: 4.h),
_row('Weight', weight == null ? '—' : '$weight kg'),
_divider(),
_row('Category', _s(widget.shipment['category'])),
@@ -374,9 +381,12 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
}
Widget _paymentCard() {
return AppCard(
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(title: 'Payment'),
SizedBox(height: 10.h),
Row(
children: [
for (final m in ShipmentPayMethod.values) ...[
@@ -425,7 +435,7 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
padding: EdgeInsets.symmetric(vertical: 12.h),
decoration: BoxDecoration(
color: selected
? ColorConstants.primary.withValues(alpha: 0.08)
? ColorConstants.tint(ColorConstants.primary, 0.08)
: ColorConstants.daylightSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(

View File

@@ -299,7 +299,7 @@ Future<void> _showMyOptionsSheet(
// rather than a variant.
const MilerSheetHeader(
title: 'Skip this pickup',
subtitle: 'Tell us why, so the hub can reassign it.',
subtitle: 'Tell us why, so your office can reassign it.',
),
SizedBox(height: 16.h),
// ── Which pickup ──

View File

@@ -427,18 +427,25 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _back();
},
// ── White page, tinted panels ──
// ── Grey page, white bands ──
//
// Dropping the borders left the sections floating: a white card on a
// near-white page has no edge at all, so five panels read as one long
// undifferentiated form. Inverting it is what the Home stop card already
// does — the page is white and the card carries the fill — and it is the
// only way to separate surfaces without drawing a line around each one.
// See [ColorConstants.cardSurface] for why the fill sits 9.4% off white
// rather than the ~3% a "subtle" grey would give: this screen is filled
// in outdoors.
// It was the other way round — a white page carrying tinted panels — and
// that inversion was itself a fix for panels that would not separate.
// Both readings work; this one is taken because of what it does to the
// *width*. A tinted panel has to be inset from the page or its fill runs
// into the screen edge, so every section sat inside a 20pt gutter and the
// page spent 40 of its 390 points on two strips of white that said
// nothing. A white band on a grey ground has no such problem: it can run
// the full width, because the ground showing above and below it is what
// separates one section from the next.
//
// So the sections are full-bleed now and the gutter lives *inside* them,
// where it is padding around content rather than margin around a box.
// Same air next to the words, ~40pt more of it for the words themselves —
// which on this screen is the difference between an address on two lines
// and an address on three.
child: Scaffold(
backgroundColor: ColorConstants.pureSurface,
backgroundColor: ColorConstants.daylightSurface,
// ── The bar breathes, and its title is a title ──
//
// 17sp jammed against the status bar is a system default, not a
@@ -462,15 +469,17 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 24.h),
// No horizontal padding: every child of this column is a
// full-bleed band that carries its own. See the note on the
// scaffold's ground.
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stopSummary(),
// No rule under the summary any more: the steps below
// are panels with their own edges, and a divider between a
// card and a card is a line drawn between two boundaries
// that already exist.
// are bands with the page's ground between them, and a
// divider between two boundaries is a third boundary.
if (_hasReview && _step >= _legs.length) ...[
SizedBox(height: 6.h),
_reviewStep(),
@@ -639,136 +648,155 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
// first thing on the page, so every leg opened by drawing the eye to the one
// block that needs nothing done to it. Unboxed it reads as a heading, which
// is what it is.
/// The stop, as the band the form is being filled in *about*.
///
/// Full-bleed white, like every section under it, so the page reads as one
/// stack of bands rather than a heading floating over a stack of boxes.
Widget _stopSummary() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Both halves give ground, neither is cut off ──
//
// A fixed badge, a `Spacer` and a fixed amount: the row was wider
// than a 360pt phone from 1.3x system text upward, and the rider who
// has turned his font up is the one who most needs to read "₹2450 to
// collect". Flexible on both sides costs nothing at normal size —
// each still takes its natural width — and lets the longer one
// ellipsise instead of striping the card black and yellow.
Row(
children: [
// The stop type as a dotted label rather than a filled badge. It
// is an attribute of the name below it, and a saturated chip made
// it the loudest thing on a screen whose subject is the form.
Container(
width: 6.w,
height: 6.w,
margin: EdgeInsets.only(right: 8.w),
decoration: BoxDecoration(
color: _kind.accent,
shape: BoxShape.circle,
),
),
Flexible(
child: Text(
_kind.titleLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
return MilerBand(
padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Both halves give ground, neither is cut off ──
//
// A fixed badge, a `Spacer` and a fixed amount: the row was wider
// than a 360pt phone from 1.3x system text upward, and the rider who
// has turned his font up is the one who most needs to read "₹2450 to
// collect". Flexible on both sides costs nothing at normal size —
// each still takes its natural width — and lets the longer one
// ellipsise instead of striping the card black and yellow.
Row(
children: [
// The stop type as a dotted label rather than a filled badge. It
// is an attribute of the name below it, and a saturated chip made
// it the loudest thing on a screen whose subject is the form.
Container(
width: 6.w,
height: 6.w,
margin: EdgeInsets.only(right: 8.w),
decoration: BoxDecoration(
color: _kind.accent,
fontFamily: FontConstants.fontFamily,
shape: BoxShape.circle,
),
),
),
const Spacer(),
if (_cash > 0)
Flexible(
child: Text(
'₹${_cash.toStringAsFixed(0)} to collect',
_kind.titleLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.acceptGreen,
color: _kind.accent,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
SizedBox(height: 8.h),
Text(
_customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
const Spacer(),
if (_cash > 0)
Flexible(
child: Text(
'₹${_cash.toStringAsFixed(0)} to collect',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
SizedBox(height: 5.h),
// The pin is doing work, not decorating: this line and the customer
// name above it are both grey-on-white running text at a glance, and
// the glyph is what says which of the two is the place.
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: 2.h, right: 6.w),
child: Icon(
LucideIcons.mapPin,
size: 14.sp,
color: ColorConstants.secondaryText,
),
SizedBox(height: 8.h),
Text(
_customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
Expanded(
child: Text(
_address,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 13.sp,
height: 1.4,
color: ColorConstants.onSurfaceVariant,
),
SizedBox(height: 5.h),
// The pin is doing work, not decorating: this line and the customer
// name above it are both grey-on-white running text at a glance, and
// the glyph is what says which of the two is the place.
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: 2.h, right: 6.w),
child: Icon(
LucideIcons.mapPin,
size: 14.sp,
color: ColorConstants.secondaryText,
),
),
),
],
),
SizedBox(height: 12.h),
// Wrap rather than Row: on a combined stop the two pills together are
// wider than the page once the system font is up, and the second one
// belongs on its own line rather than off the edge of the screen.
Wrap(
spacing: 7.w,
runSpacing: 7.h,
children: [
if (_expectedDelivery > 0)
_loadPill(
LucideIcons.truck,
'Hand over $_expectedDelivery',
ColorConstants.deliveryAccent,
Expanded(
child: Text(
_address,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 13.sp,
height: 1.4,
color: ColorConstants.onSurfaceVariant,
),
),
),
if (_expectedPickup > 0)
_loadPill(
LucideIcons.package,
'Collect $_expectedPickup',
ColorConstants.pickupAccent,
),
],
),
],
],
),
SizedBox(height: 12.h),
// Wrap rather than Row: on a combined stop the two pills together are
// wider than the page once the system font is up, and the second one
// belongs on its own line rather than off the edge of the screen.
Wrap(
spacing: 7.w,
runSpacing: 7.h,
children: [
if (_expectedDelivery > 0)
_loadPill(
LucideIcons.truck,
'Hand over $_expectedDelivery',
ColorConstants.deliveryAccent,
),
if (_expectedPickup > 0)
_loadPill(
LucideIcons.package,
'Collect $_expectedPickup',
ColorConstants.pickupAccent,
),
],
),
],
),
);
}
/// A finished band's ground: the accept green at 5.5%, resolved onto white
/// so it is an ordinary opaque colour rather than a wash that changes with
/// what is behind it.
static final Color _doneFill = ColorConstants.tint(
ColorConstants.acceptGreen,
0.055,
);
/// The inset a band keeps around its content — the app's own, so this page
/// and the shipment desk it hands off to line up at the same left margin.
double get _bandPad => MilerBand.pad;
Widget _loadPill(IconData icon, String label, Color accent) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 6.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
color: ColorConstants.tint(accent, 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
@@ -897,7 +925,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
decoration: BoxDecoration(
color: ticked
? ColorConstants.acceptGreen.withValues(alpha: 0.10)
? ColorConstants.tint(ColorConstants.acceptGreen, 0.10)
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
// The tick is the whole point of this control, so the untouched state
@@ -1167,10 +1195,12 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The heading lives on the page, not in a card — this is the screen
// talking, not a section of it.
Padding(
padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 0),
// The heading is the screen talking rather than a section of it, but it
// is still a band: with full-bleed sections under it, a heading inset
// by its own margin is the one thing on the page whose left edge does
// not line up with everything else.
MilerBand(
padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1219,7 +1249,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
],
),
),
SizedBox(height: 14.h),
SizedBox(height: MilerBand.gap),
if (_kind.hasDelivery)
_reviewLeg(
@@ -1251,7 +1281,8 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
],
),
if (_kind.hasDelivery && _kind.hasPickup) SizedBox(height: 12.h),
if (_kind.hasDelivery && _kind.hasPickup)
SizedBox(height: MilerBand.gap),
if (_kind.hasPickup)
_reviewLeg(
@@ -1296,7 +1327,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
),
if (_cash > 0) ...[
SizedBox(height: 12.h),
SizedBox(height: MilerBand.gap),
_notice(
'Collect ₹${_cash.toStringAsFixed(0)} from the customer on the '
'next screen',
@@ -1326,13 +1357,10 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
required bool done,
File? photo,
}) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
// White, and full-bleed like every other band. It was a tinted rounded card,
// which on a grey page is a box on a box: the ground between two bands
// already says these are two legs of the stop.
return MilerBand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1375,8 +1403,8 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
return Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
color: ColorConstants.tint(color, 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -1425,7 +1453,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h),
decoration: BoxDecoration(
color: ColorConstants.warning.withValues(alpha: 0.10),
color: ColorConstants.tint(ColorConstants.warning, 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
@@ -1904,26 +1932,19 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
final accent = done ? ColorConstants.acceptGreen : _legAccent;
return Padding(
padding: EdgeInsets.only(top: 14.h),
child: AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOut,
width: double.infinity,
padding: EdgeInsets.fromLTRB(16.w, 15.h, 16.w, 16.h),
decoration: BoxDecoration(
// Done recedes; outstanding keeps the page. Neither is a border
// colour — a filled state survives sunlight and a 1px edge does not.
color: done
? ColorConstants.acceptGreen.withValues(alpha: 0.055)
: ColorConstants.daylightSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: done
? ColorConstants.acceptGreen.withValues(alpha: 0.22)
: ColorConstants.borderSubtle,
width: 1.2,
),
),
padding: EdgeInsets.only(top: MilerBand.gap),
// ── State in the fill, and the fill is a colour rather than a wash ──
//
// Done recedes and outstanding keeps the page; that is the right property
// to carry it, because a fill is the one thing readable at arm's length
// in sun. Both states are opaque colours rather than the accent laid over
// whatever is behind (see [ColorConstants.tint]), and the finished band
// adds the rail, which is what replaced the tinted outline these sections
// used to close with. See [MilerBand].
child: MilerBand(
color: done ? _doneFill : ColorConstants.pureSurface,
rail: done ? ColorConstants.acceptGreen : null,
padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 17.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1935,7 +1956,11 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
height: 30.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.11),
color: ColorConstants.tint(
accent,
0.11,
on: done ? _doneFill : ColorConstants.pureSurface,
),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
@@ -2020,7 +2045,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
return Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: ColorConstants.warning.withValues(alpha: 0.10),
color: ColorConstants.tint(ColorConstants.warning, 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Text(
@@ -2274,7 +2299,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
padding: EdgeInsets.symmetric(vertical: 30.h),
width: double.infinity,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.06),
color: ColorConstants.tint(accent, 0.06),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: accent.withValues(alpha: 0.30), width: 1.4),
),
@@ -2302,9 +2327,14 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
Widget _notice(String text, Color accent) {
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(horizontal: _bandPad),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
color: ColorConstants.tint(
accent,
0.10,
on: ColorConstants.daylightSurface,
),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(

View File

@@ -23,6 +23,7 @@ import 'package:miler/views/Dashboard/profile/informations/order_alert_sound.dar
import 'package:miler/views/Dashboard/summary/summary.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
@@ -163,13 +164,26 @@ class _ProfilePageState extends State<ProfilePage> {
// rows on the bare background, so the whole tab read a shade dirtier than
// the three either side of it. Four tabs of one app should not disagree
// about what colour the paper is.
// The brand shows through the sheet's two top corners — see [MilerSheet].
// ── The corner has to have something behind it ──
//
// Account had the sheet but not the shape: its two top corners clipped
// onto a white ground, so the curve revealed white and read as a
// rounding artefact rather than as the brand coming through. The brand
// goes behind the sheet, the same as the other three tabs; the page's
// own colour is the sheet's [MilerSurface.canvas].
backgroundColor: ColorConstants.primary,
// Account was the odd tab out: no top bar at all, just a 28sp inline
// heading scrolling away with the content, while the other three sat
// under a fixed branded bar. Four tabs of one app should not disagree
// about where the page title lives.
appBar: MilerAppBar(title: 'Account', trailing: _bell()),
appBar: MilerAppBar(
title: 'Account',
trailing: _bell(),
// The bar's curve opens onto whatever is directly under it, and under
// this bar is the sheet's canvas rather than the app's paper. See
// [milerBrandSurface].
pageColor: Narrative.canvas,
),
// ── Pull to refresh ──
//
// A pull here used to drag the page off the bottom of the screen and
@@ -187,7 +201,7 @@ class _ProfilePageState extends State<ProfilePage> {
// layered pages and one flat one. Same recipe here: canvas ground, one
// panel per section, the labels on the canvas between them.
body: MilerSheet(
color: MilerSurface.canvas,
color: Narrative.canvas,
child: SettingsBody(
onRefresh: _refresh,
// The page's single left edge — the same gutter Activity uses, so a
@@ -233,8 +247,8 @@ class _ProfilePageState extends State<ProfilePage> {
// he has finished today, which is the glance he actually makes
// between stops.
Padding(
padding: EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter + MilerSurface.panelPad,
padding: const EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter,
),
child: const SettingsLabel('Money'),
),
@@ -257,8 +271,8 @@ class _ProfilePageState extends State<ProfilePage> {
SizedBox(height: 20.h),
Padding(
padding: EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter + MilerSurface.panelPad,
padding: const EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter,
),
child: const SettingsLabel('Account'),
),
@@ -305,8 +319,8 @@ class _ProfilePageState extends State<ProfilePage> {
SizedBox(height: 20.h),
Padding(
padding: EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter + MilerSurface.panelPad,
padding: const EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter,
),
child: const SettingsLabel('Support'),
),

View File

@@ -52,20 +52,21 @@ class _FaqPageState extends State<FaqPage> {
static const List<_Faq> _faqs = [
_Faq(
'What is a trip, and why can I only work one stop at a time?',
'Your hub assigns a whole slot as one trip: pick up from every customer '
'on it, then return to the hub. The stops are ordered for you, so the '
'Your office assigns a whole slot as one trip: pick up from every '
'customer on it, then return to base. The stops are ordered for you, '
'so the '
'app opens them one at a time — finish the active stop before the '
'next one unlocks.',
),
_Faq(
'A customer is not answering. Can I move them to the end?',
'No — a trip keeps its order. Skip the stop with a reason instead. It '
'comes out of your queue, your hub sees why, and the rest of the trip '
'comes out of your queue, your office sees why, and the rest of the trip '
'carries on unchanged.',
),
_Faq(
'I am running late because of traffic. What should I do?',
'Keep the app open so your location keeps reporting — the hub can see '
'Keep the app open so your location keeps reporting — your office can see '
'the delay without you calling. If a customer needs to know, use the '
'call button on the stop.',
),
@@ -79,12 +80,12 @@ class _FaqPageState extends State<FaqPage> {
_Faq(
'I collected cash. Whose money is it?',
'It is the customer\'s payment to the business you are picking up from, '
'held by you until you hand it in at the hub. Record it in the app at '
'held by you until you hand it in at the end of your shift. Record it in the app at '
'the stop so the amount you carry and the amount expected match.',
),
_Faq(
'When do I get paid, and what are points?',
'Earnings are settled by your hub on its regular cycle — Earnings shows '
'Earnings are settled by your office on its regular cycle — Earnings shows '
'what has been counted so far. Points are separate: they are earned '
'on completed trips and shown under Rewards.',
),
@@ -98,7 +99,7 @@ class _FaqPageState extends State<FaqPage> {
'How do I change my name, photo or address?',
'Account > Edit Profile for your name, photo and email; Account > Saved '
'Address for your address. Your mobile number is fixed to the one '
'your hub registered — contact support to change it.',
'your office registered — contact support to change it.',
),
];

View File

@@ -54,7 +54,7 @@ class HelpCenter extends StatelessWidget {
),
SettingsRow(
icon: LucideIcons.phone,
title: 'Call the hub',
title: 'Call the office',
subtitle: 'For anything that cannot wait',
onTap: () => _dial(context, _supportPhone),
),

View File

@@ -489,7 +489,7 @@ class _RiderRewardsPageState extends State<RiderRewardsPage> {
_ruleDivider(),
_rule(
icon: LucideIcons.route,
title: 'Ride the route your hub assigned',
title: 'Ride the route your office assigned',
body:
'Wandering well past the planned distance breaks the '
'route check for that stop.',
@@ -534,8 +534,8 @@ class _RiderRewardsPageState extends State<RiderRewardsPage> {
SizedBox(width: 7.w),
Expanded(
child: Text(
'Your hub sets what each status is worth and can change the '
'thresholds. Ask your hub manager what your level earns you.',
'Your office sets what each status is worth and can change the '
'thresholds. Ask your manager what your level earns you.',
style: TextStyle(
fontSize: 11.5.sp,
height: 1.45,

View File

@@ -132,7 +132,7 @@ class _SavedAddressPageState extends State<SavedAddressPage> {
children: [
_editing ? _editor() : _addressCard(),
const SettingsHint(
'Your hub uses this to work out which slots start near you, '
'Your office uses this to work out which slots start near you, '
'and it is where your shift is assumed to end.',
),
SizedBox(height: 24.h),

View File

@@ -8,6 +8,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/controllers/summary_controller.dart';
import 'package:miler/controllers/rewards_controller.dart';
@@ -92,59 +93,89 @@ class _SummaryState extends State<Summary> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.surface,
// ── The same ground and the same curve as the four tabs ──
//
// It was `surface`, the warm off-white, on a page with no sheet at all —
// so the one screen a rider opens to look at his money was the one screen
// that did not belong to the app around it. The brand behind the sheet's
// two top corners, the canvas under the cards, the gutter and the radii
// are all the shared ones now.
backgroundColor: ColorConstants.primary,
// Earnings is no longer a tab — it opens from Account — so it needs a way
// back that the four tabs never did.
appBar: const MilerAppBar(title: 'Earnings', showBack: true),
body: RefreshIndicator(
onRefresh: _refreshData,
color: ColorConstants.primary,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
SizedBox(height: 16.h),
_buildPeriodTabs(),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildPickupsHero(),
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildStatsGrid(),
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: TotalDistanceCard(
userId: _userId,
refreshTick: _refreshTick,
),
),
SizedBox(height: 32.h),
],
),
// `pageColor`: this Scaffold is brand-red, so the curve names the sheet
// under it instead — see [milerBrandSurface].
appBar: const MilerAppBar(
title: 'Earnings',
showBack: true,
pageColor: ColorConstants.pureSurface,
),
body: MilerSheet(
// White, like Home, Activity and the record page. It was
// [Narrative.canvas] — a grey ground for panels that carry a hairline
// and a lift of their own, so the grey was a second separator under
// things that already had one. Four screens, one colour.
color: ColorConstants.pureSurface,
child: RefreshIndicator(
onRefresh: _refreshData,
color: ColorConstants.primary,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
],
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
SizedBox(height: 14.h),
_buildPeriodTabs(),
SizedBox(height: Narrative.gapBlock.h),
Padding(
padding: EdgeInsets.symmetric(
horizontal: Narrative.gutter.w,
),
child: _buildPickupsHero(),
),
SizedBox(height: Narrative.gapBlock.h),
Padding(
padding: EdgeInsets.symmetric(
horizontal: Narrative.gutter.w,
),
child: _buildStatsGrid(),
),
SizedBox(height: Narrative.gapBlock.h),
Padding(
padding: EdgeInsets.symmetric(
horizontal: Narrative.gutter.w,
),
child: TotalDistanceCard(
userId: _userId,
refreshTick: _refreshTick,
),
),
SizedBox(height: 32.h),
],
),
),
],
),
),
),
);
}
Widget _buildPeriodTabs() {
// The app's segmented control, the same object as Activity's filter and
// Home's trip tabs: a tonal track, one white pill, the selected label in
// the brand. It was a bordered white bar with a *filled maroon* segment —
// a third idiom for the one interaction, and the only place in the app
// where a selected tab was a solid brand slab.
return Container(
margin: EdgeInsets.symmetric(horizontal: 16.w),
height: 44.h,
margin: EdgeInsets.symmetric(horizontal: Narrative.gutter.w),
height: 46.h,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.outlineVariant),
color: Narrative.insetDeep,
borderRadius: BorderRadius.circular(Narrative.radiusPill),
),
child: Row(
children: List.generate(_periods.length, (index) {
@@ -158,23 +189,23 @@ class _SummaryState extends State<Summary> {
});
}
},
child: Container(
child: AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
margin: EdgeInsets.all(4.r),
decoration: BoxDecoration(
color: isSelected
? ColorConstants.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
color: isSelected ? Narrative.card : Colors.transparent,
borderRadius: BorderRadius.circular(Narrative.radiusPill),
boxShadow: isSelected ? Narrative.cardShadow : null,
),
alignment: Alignment.center,
child: Text(
_periods[index],
style: TextStyle(
fontSize: 13.sp,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? Colors.white
: ColorConstants.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: -0.1,
color: isSelected ? Narrative.burgundy : Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
),
@@ -203,11 +234,14 @@ class _SummaryState extends State<Summary> {
// not an effect. The two faint circles behind it survive: they are what
// stops a solid maroon slab reading as a error state.
decoration: BoxDecoration(
color: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
color: Narrative.burgundy,
borderRadius: BorderRadius.circular(Narrative.radiusCard),
// The one celebratory surface in the app, and the only place the
// brand's own glow is spent.
boxShadow: Narrative.burgundyGlow,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderRadius: BorderRadius.circular(Narrative.radiusCard),
child: Stack(
children: [
Positioned(
@@ -317,14 +351,30 @@ class _SummaryState extends State<Summary> {
icon: LucideIcons.circleMinus,
),
),
SizedBox(width: 12.w),
Expanded(
child: _buildHeroMetric(
'$points',
'Reward pts',
icon: LucideIcons.star,
// ── The reward tile is withheld, not zeroed ──
//
// `bonuspoints` is never written — confirmed by the
// backend on 24 Aug, along with the fact that
// `ridercharges` holds the *client's* order price rather
// than any rider payout, and that a real rate-card is a
// separate build. A tile reading **0 Reward pts** on
// every screen of every shift does not say "not built
// yet"; it says "you have earned nothing", which is a
// worse thing to tell a rider than saying nothing.
//
// It comes back the moment the figure is real: the tile
// is drawn whenever the source is non-zero, so no code
// has to change when the backend starts writing it.
if (points > 0) ...[
SizedBox(width: 12.w),
Expanded(
child: _buildHeroMetric(
'$points',
'Reward pts',
icon: LucideIcons.star,
),
),
),
],
],
),
],
@@ -437,8 +487,15 @@ class _SummaryState extends State<Summary> {
// competes with the hero above it for the reader's first look.
Container(
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
// A hairline and a soft lift on white, rather than a tonal fill.
// The fill was carrying the separation while the page had a grey
// ground to sit on; on white it was a fourth surface tone doing
// what an edge does more quietly. Same recipe as every other card
// in the app — see [Narrative.cardBox].
color: Narrative.card,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: Narrative.cardBorder),
boxShadow: Narrative.cardShadow,
),
padding: EdgeInsets.symmetric(vertical: 16.h),
child: IntrinsicHeight(
@@ -575,6 +632,15 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3);
/// `Mon` … `Sun`, for matching a chart row against the calendar.
///
/// Written out rather than taken from `intl`: the series' own day names are
/// whatever the backend sends, and the only thing being compared is the first
/// three letters — so a locale-aware formatter would introduce a mismatch
/// this comparison cannot have.
String _weekdayName(DateTime d) =>
const ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d.weekday - 1];
@override
Widget build(BuildContext context) {
return Container(
@@ -713,16 +779,74 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
Widget _buildChart(List<RiderWeeklyKms> details, double totalKms) {
final values = details.map((e) => e.kms).toList();
final maxVal = values.reduce((a, b) => a > b ? a : b);
final safeMax = maxVal <= 0 ? 1.0 : maxVal;
final maxVal = values.isEmpty
? 0.0
: values.reduce((a, b) => a > b ? a : b);
// Best day + averages for the summary row.
int bestIdx = 0;
for (int i = 1; i < values.length; i++) {
if (values[i] > values[bestIdx]) bestIdx = i;
// ── A week with nothing in it says so ──
//
// Seven bars of zero under a chart frame is not an empty state, it is a
// chart that looks broken — and it looked exactly like the bug this page
// had for months, where the series was never being read at all. An axis
// with nothing on it should be replaced, not drawn.
if (maxVal <= 0) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 34.h),
child: Column(
children: [
Icon(
LucideIcons.chartNoAxesColumn,
size: 28.sp,
color: ColorConstants.secondaryText,
),
SizedBox(height: 10.h),
Text(
'No distance recorded this week',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 4.h),
Text(
'Kilometres appear here once a stop is completed.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5.sp,
height: 1.4,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
final safeMax = maxVal;
final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length;
// ── Today, from the same series the chart is drawn from ──
//
// Matched on the day name rather than taken from a second endpoint: the
// bars and this figure have to agree, and the only way to guarantee that is
// for them to be the same number. A week whose rows carry no day names —
// which happens, and is why `Best day` used to render blank — simply has no
// today to point at, and the row prints the em dash.
final today = _shortDay(_weekdayName(DateTime.now())).toLowerCase();
RiderWeeklyKms? todayRow;
for (final d in details) {
if (_shortDay(d.day).toLowerCase() == today) {
todayRow = d;
break;
}
}
return Column(
children: [
SizedBox(
@@ -843,17 +967,28 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
//
// These were amber / brand-red / blue — three hues for three ledger
// facts, on the one page family where colour is reserved for the hero
// and the chart's own bars. And "Best Day" rendered blank when the
// week's data carries no day names, leaving a caption with nothing
// above it; an absent fact prints the em dash like everywhere else.
// and the chart's own bars.
//
// ── `Best day` gave way to `Today` ──
//
// Best day named a weekday — `Wed` — which is a fact about the past
// that a rider can do nothing with, and it rendered blank whenever the
// week's rows carried no day names. What he opens this page to find out
// is how far he has ridden *today*, and until now the app said that
// nowhere: the distance was per-stop on Activity and per-week here,
// with nothing in between.
//
// It also puts the same figure on two screens — this and the Activity
// hero — which is deliberate: they are read at different moments, and
// both take it from a measurement rather than from each other.
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildChartStat(
'Best day',
_shortDay(details[bestIdx].day).trim().isEmpty
'Today',
todayRow == null
? '\u2014'
: _shortDay(details[bestIdx].day),
: '${todayRow.kms.toStringAsFixed(1)} km',
ColorConstants.slateText,
),
_buildChartStat(