production
This commit is contained in:
@@ -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,
|
||||
|
||||
616
lib/views/Dashboard/home/home_header.dart
Normal file
616
lib/views/Dashboard/home/home_header.dart
Normal 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
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (_) {}
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user