production
This commit is contained in:
@@ -83,6 +83,46 @@ String clockOf(DateTime t) {
|
||||
return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}';
|
||||
}
|
||||
|
||||
/// `Today`, `Yesterday`, or `17 Aug` — which day a record belongs to.
|
||||
///
|
||||
/// A clock alone is ambiguous the moment a rider opens a record that is not
|
||||
/// today's, and Activity's own rule bans the all-caps date band that used to
|
||||
/// answer it. This is the same fact as a caption on the record itself, where it
|
||||
/// costs one line rather than a header per group.
|
||||
String dayLabel(DateTime t, {DateTime? now}) {
|
||||
final today = now ?? DateTime.now();
|
||||
final day = DateTime(t.year, t.month, t.day);
|
||||
final ref = DateTime(today.year, today.month, today.day);
|
||||
final diff = ref.difference(day).inDays;
|
||||
if (diff == 0) return 'Today';
|
||||
if (diff == 1) return 'Yesterday';
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
return '${t.day} ${months[t.month - 1]}';
|
||||
}
|
||||
|
||||
/// `2:05` — the same clock as [clockOf] with the meridiem dropped.
|
||||
///
|
||||
/// For a strip of four times on one line, where the `AM`/`PM` is both the
|
||||
/// widest part of the string and the part a rider can infer from the three
|
||||
/// beside it.
|
||||
String shortClockOf(DateTime t) {
|
||||
final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12;
|
||||
return '$hour12:${t.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// A duration at a glance: `6m`, `1h 12m`.
|
||||
///
|
||||
/// Seconds are never shown — nothing on these screens is decided to the second,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -148,7 +148,7 @@ class _OrderStatusRowState extends State<OrderStatusRow> {
|
||||
children: [
|
||||
const MilerSheetHeader(
|
||||
title: 'Reject booking',
|
||||
subtitle: 'The hub re-routes it once it knows why.',
|
||||
subtitle: 'Your office re-routes it once it knows why.',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (String reason in reasons)
|
||||
@@ -208,7 +208,7 @@ class _OrderStatusRowState extends State<OrderStatusRow> {
|
||||
children: [
|
||||
const MilerSheetHeader(
|
||||
title: 'Cancel this booking?',
|
||||
subtitle: 'It returns to pending and the hub reassigns it.',
|
||||
subtitle: 'It returns to pending and your office reassigns it.',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
CheckboxListTile(
|
||||
|
||||
@@ -816,12 +816,23 @@ class _CallButton extends StatelessWidget {
|
||||
/// the kitchen's. See [StopContact].
|
||||
final String? semanticLabel;
|
||||
|
||||
const _CallButton({required this.phone, this.size, this.semanticLabel});
|
||||
/// A disc rather than a rounded square. The sheets draw it beside a round
|
||||
/// avatar, where a square tile reads as a control belonging to something
|
||||
/// else; the cards keep the square, which matches the tiles around it.
|
||||
final bool round;
|
||||
|
||||
const _CallButton({
|
||||
required this.phone,
|
||||
this.size,
|
||||
this.semanticLabel,
|
||||
this.round = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SquareIconButton(
|
||||
icon: LucideIcons.phone,
|
||||
round: round,
|
||||
size: size ?? math.max(52.0, 52.w),
|
||||
// ── Green, and the same green everywhere a phone appears ──
|
||||
//
|
||||
@@ -833,9 +844,9 @@ class _CallButton extends StatelessWidget {
|
||||
// app he already uses — it is what the glyph means before the palette
|
||||
// gets a say — and "done" is carried by a tick and a word, not by a hue
|
||||
// on a handset.
|
||||
background: ColorConstants.acceptGreen.withValues(alpha: 0.12),
|
||||
background: Narrative.emeraldWash,
|
||||
border: Colors.transparent,
|
||||
iconColor: ColorConstants.acceptGreen,
|
||||
iconColor: Narrative.emerald,
|
||||
semanticLabel: semanticLabel ?? 'Call this stop',
|
||||
onTap: () async {
|
||||
// A dialer that fails to open is silent by default: the rider is at
|
||||
@@ -859,6 +870,7 @@ class _SquareIconButton extends StatelessWidget {
|
||||
final Color iconColor;
|
||||
final String semanticLabel;
|
||||
final VoidCallback onTap;
|
||||
final bool round;
|
||||
|
||||
const _SquareIconButton({
|
||||
required this.icon,
|
||||
@@ -868,25 +880,29 @@ class _SquareIconButton extends StatelessWidget {
|
||||
required this.iconColor,
|
||||
required this.semanticLabel,
|
||||
required this.onTap,
|
||||
this.round = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(
|
||||
round ? DesignConstants.radiusFull : DesignConstants.radiusXl,
|
||||
);
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: semanticLabel,
|
||||
child: Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: border, width: 1.5),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 22.sp),
|
||||
@@ -1044,71 +1060,21 @@ class _QueueHeading extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (order != null) ...[
|
||||
const Spacer(),
|
||||
Flexible(child: _OrderProvenance(source: order!)),
|
||||
],
|
||||
// ── The provenance chip is gone ──
|
||||
//
|
||||
// `As assigned` / `Nearest first` sat at the head of the queue, in a
|
||||
// tinted pill with a glyph, telling the rider where the *ordering* of
|
||||
// his list came from. It is a fact about this app's sorting, not
|
||||
// about his work: he cannot act on it, he cannot change it, and on
|
||||
// the common day it said the same three words every time he opened
|
||||
// the tab. What he needs from that row is which trip and how far
|
||||
// through it he is, and both are already on it.
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hub route` / `Nearest first` — where the queue's order came from.
|
||||
///
|
||||
/// Deliberately quiet. It is a provenance mark, not a warning: nearest-first
|
||||
/// is a reasonable order when nobody has assigned one, and the rider does not
|
||||
/// need alarming about it. What he needs is to not be told it is his route.
|
||||
class _OrderProvenance extends StatelessWidget {
|
||||
final RouteOrderSource source;
|
||||
|
||||
const _OrderProvenance({required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final assigned = source.isAdmin;
|
||||
final tint = assigned
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.secondaryText;
|
||||
|
||||
return Tooltip(
|
||||
message: source.explanation,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: assigned ? 0.10 : 0.07),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
assigned ? LucideIcons.route : LucideIcons.navigation,
|
||||
size: 11.sp,
|
||||
color: tint,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
source.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: tint,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Bag 1 · Joe` — the pairing, as one object.
|
||||
///
|
||||
/// ── Why they are one line and not two facts ──
|
||||
|
||||
@@ -322,9 +322,10 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
|
||||
if (_due <= 0) return _nothingToCollect();
|
||||
|
||||
return Scaffold(
|
||||
// White page, tinted panels — the same inversion the verification screens
|
||||
// it follows now use. See the note there.
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
// Grey page, white bands — the same shape the verification screens this
|
||||
// follows now take, so the money step reads as the next page of one flow
|
||||
// rather than a different screen. See [MilerBand].
|
||||
backgroundColor: ColorConstants.daylightSurface,
|
||||
// Same bar as the verification screens this follows: taller, clear of
|
||||
// the notch, title at a size that survives sunlight and arm's length.
|
||||
appBar: milerPageBar(
|
||||
@@ -336,18 +337,19 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 24.h),
|
||||
// The bands carry the gutter. See [MilerBand].
|
||||
padding: EdgeInsets.only(bottom: 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_amountCard(),
|
||||
SizedBox(height: 14.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_orderCard(),
|
||||
SizedBox(height: 14.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_methodCard(),
|
||||
SizedBox(height: 14.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_shortCard(),
|
||||
SizedBox(height: 14.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_custodyNote(),
|
||||
],
|
||||
),
|
||||
@@ -366,13 +368,8 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
|
||||
// door, and a number he can edit is a number he will be argued into
|
||||
// changing.
|
||||
Widget _amountCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.fromLTRB(18.w, 20.h, 18.w, 18.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.cardSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
return MilerBand(
|
||||
padding: EdgeInsets.fromLTRB(MilerBand.pad, 20.h, MilerBand.pad, 18.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -822,13 +819,10 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
|
||||
// once, plainly, at the moment he takes it.
|
||||
Widget _custodyNote() {
|
||||
final onBehalf = _shipper.isNotEmpty ? _shipper : 'the shipper';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(13.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.tertiary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
return MilerBand(
|
||||
color: ColorConstants.tint(ColorConstants.tertiary, 0.08),
|
||||
rail: ColorConstants.tertiary,
|
||||
padding: EdgeInsets.fromLTRB(MilerBand.pad, 13.h, MilerBand.pad, 13.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -837,7 +831,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This money belongs to $onBehalf, not to you and not to Miler. '
|
||||
'Deposit it at the hub at the end of your shift.',
|
||||
'Hand it in at the end of your shift.',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
height: 1.4,
|
||||
@@ -1049,13 +1043,7 @@ class _CollectPaymentPageState extends State<CollectPaymentPage> {
|
||||
// ── Shared pieces ──────────────────────────────────────────────────────
|
||||
|
||||
Widget _card({required String title, required Widget child}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.cardSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
return MilerBand(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -214,25 +214,142 @@ Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
|
||||
/// (it left `Collected_By_Miler` some other way) and a lingering
|
||||
/// `IDEMPOTENCY_IN_PROGRESS` after [MilerApi] has waited the write out.
|
||||
/// Neither is answerable from the error itself.
|
||||
/// Why the last [releaseForDelivery] said no, in the rider's words.
|
||||
///
|
||||
/// ── One message was covering four different problems ──
|
||||
///
|
||||
/// Every failure below returned a bare `false`, and the caller printed *"This
|
||||
/// parcel isn't ready to go out yet. Check your connection and try again."* for
|
||||
/// all of them. Three of the four have nothing to do with a connection, and
|
||||
/// two of them cannot be fixed by trying again at all:
|
||||
///
|
||||
/// • **No consignment.** The pickup never converted, or its id was lost. The
|
||||
/// rider can retry this until his battery dies.
|
||||
/// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be
|
||||
/// delivered by somebody else. There is nothing here for him to start.
|
||||
/// • **Refused.** The server gave a reason and it was thrown away.
|
||||
/// • **Unreachable.** The only one where "check your connection" is true.
|
||||
///
|
||||
/// Telling a rider to check a working connection, about a parcel that is not
|
||||
/// his to deliver, is how a screen teaches him to ignore what it says. Null
|
||||
/// after a success.
|
||||
String? lastReleaseFailure;
|
||||
|
||||
/// Whether a "somebody else has this" hold is even possible on this rider's
|
||||
/// line.
|
||||
///
|
||||
/// ── The state that cannot happen, refusing the rider anyway ──
|
||||
///
|
||||
/// `Inwarded_at_Hub`, `Tripsheet_Loaded` and `In_Transit` are the logistics
|
||||
/// network's own rungs: a cross-city parcel is dropped at a collection point
|
||||
/// and delivered by a different rider on a different day. That is a real hold
|
||||
/// and it must keep blocking on the logistics line.
|
||||
///
|
||||
/// On a meal run **it cannot happen at all.** Every DailyGrubs order is
|
||||
/// hyperlocal — collected at the kitchen and carried straight to the door, one
|
||||
/// rider, one leg, no network in between. So one of those states coming back
|
||||
/// for a meal stop is not news about the food; it is a bad read, and the two
|
||||
/// ways it happens are both known: a consignment reference that resolved to
|
||||
/// the wrong record, and a row whose state belongs to a different booking.
|
||||
///
|
||||
/// The rider was shown the hold anyway, mid-round, on a slider whose only job
|
||||
/// is to set him off — and there was nothing he could do about it, because the
|
||||
/// thing it described was not true of his stop.
|
||||
///
|
||||
/// So a hold is only believed on a line that has somewhere to hold things. On
|
||||
/// every other line the state is treated as unreadable, which sends the
|
||||
/// question to the server: it is the judge of its own consignment, and one
|
||||
/// refusal carrying its own reason beats a client-side refusal inventing one.
|
||||
bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub;
|
||||
|
||||
Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
||||
lastReleaseFailure = null;
|
||||
|
||||
final consignmentId = await resolveConsignmentId(stop);
|
||||
if (consignmentId.isEmpty) {
|
||||
debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}');
|
||||
lastReleaseFailure =
|
||||
"This stop has no delivery reference yet, so it can't be started. "
|
||||
'Ask your office to check it — sliding again will not help.';
|
||||
return false;
|
||||
}
|
||||
|
||||
// What the list row already told us, before spending a request on it.
|
||||
var state = consignmentStateFromRaw(stop['consignmentstatus']);
|
||||
if (state == ConsignmentState.unknown) {
|
||||
// ── A row saying `Created` is the one row worth re-reading ──
|
||||
//
|
||||
// `Created` is the state a consignment holds for as long as it takes the
|
||||
// pivot to route it, and `GET /miler/bookings` is a poll behind the rider by
|
||||
// design. So a stop collected seconds ago can arrive on this tab carrying
|
||||
// the state it had *before* `pickup-complete` finished with it, and every
|
||||
// other state on the row is durable enough not to have that problem.
|
||||
//
|
||||
// Acting on it cost the rider the round: the guard below read it as hub
|
||||
// custody and refused, and no amount of sliding cleared a row that only the
|
||||
// next poll was going to correct. It is asked directly instead — one request,
|
||||
// on the one state where the row is not evidence.
|
||||
if (state == ConsignmentState.unknown ||
|
||||
state == ConsignmentState.created) {
|
||||
state = await ConsignmentGate.stateOf(consignmentId);
|
||||
}
|
||||
if (state.isDeliverable || state.isDelivered) return true;
|
||||
if (!state.needsRelease && state != ConsignmentState.unknown) {
|
||||
if (state.isDeliverable || state.isDelivered) {
|
||||
// ── Released without a request, and the trace has to say so ──
|
||||
//
|
||||
// In compatibility mode the pivot has already released the consignment, so
|
||||
// Start ride spends nothing here and the console was *already* Active
|
||||
// before the rider slid. A silent `true` made that indistinguishable from
|
||||
// a release this press actually made, which is the exact ambiguity the
|
||||
// production trace has to resolve.
|
||||
debugPrint(
|
||||
'[TRACE][START-RIDE] consignment=$consignmentId '
|
||||
'state="${state.name}" startDeliveryCalled=false '
|
||||
'reason=already-released-by-pickup-complete (compatibility mode)',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// ── What may be *attempted*, as against what may be *claimed* ──
|
||||
//
|
||||
// This refused everything that was not `Collected_By_Miler` or unreadable,
|
||||
// and `Created` fell into it wearing the hub's wording. Both halves were
|
||||
// wrong for a parcel on the rider's own back.
|
||||
//
|
||||
// `Created` now goes to the server like `unknown` does, for the reason
|
||||
// `unknown` does: the app is not the judge of a state it cannot fully
|
||||
// account for, and one 400 carrying the backend's own sentence is worth more
|
||||
// than a client-side refusal that invents one. A hyperlocal consignment the
|
||||
// pivot has not finished releasing is let through; a hub-routed one is
|
||||
// refused by the server, and the rider is told what the server said.
|
||||
// A hold that cannot exist on this line is a bad read, not a fact about the
|
||||
// stop. See [_handoffHoldIsPossible].
|
||||
final heldElsewhere = state.awaitsHub && _handoffHoldIsPossible;
|
||||
if (state.awaitsHub && !_handoffHoldIsPossible) {
|
||||
debugPrint(
|
||||
'[MILKRUN] $consignmentId read as ${state.name} on a line with no '
|
||||
'handoff — treating as unreadable and asking the server',
|
||||
);
|
||||
}
|
||||
if (!state.needsRelease &&
|
||||
!state.awaitsHubInward &&
|
||||
!(state.awaitsHub && !_handoffHoldIsPossible) &&
|
||||
state != ConsignmentState.unknown) {
|
||||
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
|
||||
lastReleaseFailure = heldElsewhere
|
||||
// The honest answer for a cross-city parcel: it is not his round, and
|
||||
// saying so without naming a building he has never been to.
|
||||
? "This one has already been handed on — it's not yours to deliver."
|
||||
: 'This parcel cannot be started yet. Ask your office to check it.';
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[TRACE][START-RIDE] consignment=$consignmentId state="${state.name}" '
|
||||
'POST /miler/consignments/$consignmentId/start-delivery — calling',
|
||||
);
|
||||
final res = await MilerApi.startDelivery(consignmentId);
|
||||
debugPrint(
|
||||
'[TRACE][START-RIDE] consignment=$consignmentId startDeliveryCalled=true '
|
||||
'-> ${res.status} ${res.code} ok=${res.ok} raw=${res.raw}',
|
||||
);
|
||||
if (res.ok) return true;
|
||||
|
||||
debugPrint(
|
||||
@@ -242,7 +359,16 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
||||
// Already on the road, or already handed over — either way this press is
|
||||
// not what stands between the rider and the door.
|
||||
final after = await ConsignmentGate.stateOf(consignmentId);
|
||||
return after.isDeliverable || after.isDelivered;
|
||||
if (after.isDeliverable || after.isDelivered) return true;
|
||||
|
||||
// `status: 0` is the request never leaving; anything else is the server
|
||||
// answering, and its own words beat a guess about the network.
|
||||
lastReleaseFailure = res.status == 0
|
||||
? 'Could not reach your office. Check your connection and slide again.'
|
||||
: (res.message.trim().isNotEmpty
|
||||
? res.message.trim()
|
||||
: 'Your office would not start this delivery.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Brings this device into line with a delivery the server already has.
|
||||
@@ -386,15 +512,42 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
}
|
||||
return {'outcome': 'completed', 'reconciled': true};
|
||||
|
||||
case DeliverGate.awaitingHub:
|
||||
// The guard that must survive: a logistics consignment sitting at a
|
||||
// hub genuinely is not this rider's to hand over.
|
||||
debugPrint('[DELIVERY] $orderId is with the hub — refusing');
|
||||
case DeliverGate.awaitingInward:
|
||||
// Converted but never released and never inwarded. It is in his box,
|
||||
// so the hub-hold sentence below would be false — but `deliver` will
|
||||
// refuse it, so saying nothing and posting anyway would put a 400 on
|
||||
// screen with no explanation attached.
|
||||
debugPrint('[DELIVERY] $orderId is Created — not on the road yet');
|
||||
if (context.mounted) {
|
||||
AppFeedback.error(
|
||||
context,
|
||||
'This parcel is still with the hub. It can be delivered once the '
|
||||
'hub releases it.',
|
||||
"This parcel hasn't been released for delivery yet. Slide to "
|
||||
'start the ride first, or ask your office to check it.',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case DeliverGate.awaitingHub:
|
||||
// The guard that must survive: a logistics consignment inside the
|
||||
// network genuinely is not this rider's to hand over.
|
||||
//
|
||||
// But only on a line that HAS a network. The same bad read that hit
|
||||
// the Start-ride slider reaches this branch too, and refusing here
|
||||
// is worse — the rider is at the door with the food in his hand.
|
||||
// See [_handoffHoldIsPossible].
|
||||
if (!_handoffHoldIsPossible) {
|
||||
debugPrint(
|
||||
'[DELIVERY] $orderId read as held on a line with no handoff — '
|
||||
'ignoring the read and letting the server judge',
|
||||
);
|
||||
break;
|
||||
}
|
||||
debugPrint('[DELIVERY] $orderId is held elsewhere — refusing');
|
||||
if (context.mounted) {
|
||||
AppFeedback.error(
|
||||
context,
|
||||
"This one has already been handed on, so it can't be delivered "
|
||||
'from here.',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -441,9 +594,18 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) {
|
||||
// Cancel is a *booking* route, so it is the one outcome that still works
|
||||
// without a consignment.
|
||||
if (outcome == DeliveryOutcome.delivered && consignmentId.isEmpty) {
|
||||
// ── Only `deliver` needs a consignment ──
|
||||
//
|
||||
// This guard used to catch **skip** as well, and skip is the one thing a
|
||||
// rider does when a delivery goes wrong — so the outcome he reaches for
|
||||
// precisely when something is already wrong was the one refused with "this
|
||||
// order's delivery reference is missing". Nothing he could do cleared it.
|
||||
//
|
||||
// Cancel was already exempt: it is a *booking* route. Skip now has one too
|
||||
// (`POST /miler/bookings/:id/skip`, shipped 24 Aug), so both work without a
|
||||
// consignment and only the hand-over — which genuinely keys on one — is
|
||||
// held here.
|
||||
//
|
||||
// Reaching here means four sources came back empty, which is no longer a
|
||||
// client-side gap: it means the pickup never converted this booking. The
|
||||
@@ -492,7 +654,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
context,
|
||||
collected
|
||||
? "This order's delivery reference is missing, so it cannot be "
|
||||
'completed from the app. Ask your hub to check it — '
|
||||
'completed from the app. Ask your office to check it — '
|
||||
'collecting it again will not help.'
|
||||
: 'This order was never picked up on the system — mark it picked '
|
||||
'up from Home first, then deliver.',
|
||||
@@ -516,6 +678,38 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
// No fix, and the outcome still has to be recordable. See above.
|
||||
}
|
||||
|
||||
// ── The photograph leaves the phone ──
|
||||
//
|
||||
// It never could: `deliver` takes `photourl`, which wants a URL, and nothing
|
||||
// on the contract accepted an upload — so proof of delivery lived in the
|
||||
// app's own directory and died with the next reinstall, on the one record
|
||||
// somebody asks about weeks later.
|
||||
//
|
||||
// `POST /miler/uploads/sign` (24 Aug) closes it: sign, PUT the bytes, send
|
||||
// the public URL back on the delivery.
|
||||
//
|
||||
// **A failed upload never blocks a hand-over.** The parcel is in the
|
||||
// customer's hands whatever the network did, and `deliver` accepts an empty
|
||||
// `photourl`. The local copy is kept either way, so a failure costs the hub
|
||||
// its copy and costs the rider nothing.
|
||||
String proofUrl = '';
|
||||
if (outcome == DeliveryOutcome.delivered && proofPath.isNotEmpty) {
|
||||
final uploaded = await MilerApi.uploadProof(
|
||||
File(proofPath),
|
||||
purpose: MilerApi.proofDelivery,
|
||||
consignmentId: consignmentId.isEmpty ? null : consignmentId,
|
||||
);
|
||||
if (uploaded != null) {
|
||||
proofUrl = uploaded;
|
||||
} else {
|
||||
ApiConfig.logGap(
|
||||
'deliver',
|
||||
'the proof photo for $orderId could not be uploaded; the delivery is '
|
||||
'being recorded without one and the copy stays on the device.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
try {
|
||||
switch (outcome) {
|
||||
@@ -523,6 +717,7 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
ok = await dc.updateDeliveredStatus(
|
||||
pickupId: pickupIdInt,
|
||||
consignmentId: consignmentId,
|
||||
proofImage: proofUrl,
|
||||
deliveredToName:
|
||||
(stop['dropcustomer'] ??
|
||||
stop['pickupcustomer'] ??
|
||||
@@ -544,15 +739,55 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
// fall back to the booking id and skip the wrong thing.
|
||||
//
|
||||
// The pickup-leg skip keeps that method; this is the delivery leg's.
|
||||
final res = await MilerApi.skipConsignment(
|
||||
consignmentId,
|
||||
reason: notes.isEmpty ? 'Skipped by rider' : notes,
|
||||
lat: riderFix?.latitude,
|
||||
lon: riderFix?.longitude,
|
||||
);
|
||||
//
|
||||
// ── Two routes, chosen by what the stop actually has ──
|
||||
//
|
||||
// With a consignment the skip belongs to it — that is the attempt
|
||||
// counter the hub reads. Without one, the booking route is not a
|
||||
// fallback but the *correct* call: a stop with no consignment has not
|
||||
// been converted, so there is nothing on the delivery side to bump.
|
||||
// Posting an empty id to `/consignments//skip` — which is what this
|
||||
// did whenever the id was unreachable — 404s and tells the rider his
|
||||
// own stop is broken.
|
||||
final res = consignmentId.isEmpty
|
||||
? await MilerApi.skipBooking(
|
||||
stop['bookingid'] ?? stop['orderheaderid'] ?? orderId,
|
||||
reason: notes.isEmpty ? 'Skipped by rider' : notes,
|
||||
lat: riderFix?.latitude,
|
||||
lon: riderFix?.longitude,
|
||||
)
|
||||
: await MilerApi.skipConsignment(
|
||||
consignmentId,
|
||||
reason: notes.isEmpty ? 'Skipped by rider' : notes,
|
||||
lat: riderFix?.latitude,
|
||||
lon: riderFix?.longitude,
|
||||
);
|
||||
ok = res.ok;
|
||||
if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}');
|
||||
|
||||
// ── The attempt counter is the hub's, and it has a ceiling ──
|
||||
//
|
||||
// `skip` returns `attemptcount` and it is the source of truth: the
|
||||
// consignment stays `Out_for_Delivery` and the parcel stays in the
|
||||
// rider's hands, so nothing here may close the stop. At **3** the
|
||||
// backend raises an Undeliverable exception for the hub, and there is
|
||||
// no automated return or reassignment behind it — a human picks it up
|
||||
// from there. Saying so is the difference between a rider trying a
|
||||
// fourth time and a rider ringing the hub.
|
||||
if (ok) {
|
||||
final body = res.data;
|
||||
final attempts = body is Map
|
||||
? int.tryParse('${body['attemptcount'] ?? ''}') ?? 0
|
||||
: 0;
|
||||
if (attempts >= 3 && context.mounted) {
|
||||
AppFeedback.info(
|
||||
context,
|
||||
'Third attempt on this stop — your office has been told and will '
|
||||
'take it from here.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case DeliveryOutcome.cancelled:
|
||||
ok = await dc.updateCancelledStatus(
|
||||
pickupId: pickupIdInt,
|
||||
@@ -574,10 +809,26 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
// show a finished screen while the office still had the order live. The
|
||||
// rider keeps the stop and gets told why.
|
||||
if (context.mounted) {
|
||||
// ── Cancel is a booking route, and a collected parcel is past it ──
|
||||
//
|
||||
// `POST /miler/bookings/:id/cancel` is refused once the stop is picked
|
||||
// up — by design: cancelling releases the booking for reassignment, and
|
||||
// a parcel already in a rider's box cannot be handed to somebody else.
|
||||
// The generic "could not record — try again" invited exactly the retry
|
||||
// that can never work, so the one outcome with a *permanent* reason says
|
||||
// it.
|
||||
final collectedAlready =
|
||||
outcome == DeliveryOutcome.cancelled &&
|
||||
(stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg);
|
||||
|
||||
AppFeedback.error(
|
||||
context,
|
||||
dc.lastBlockedReason ??
|
||||
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
|
||||
collectedAlready
|
||||
? 'You are already carrying this parcel, so it cannot be '
|
||||
'cancelled from the app. Skip it to try again later, or ask '
|
||||
'your office to cancel it.'
|
||||
: dc.lastBlockedReason ??
|
||||
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// PROOF OF DELIVERY
|
||||
@@ -42,6 +44,37 @@ import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||||
/// without a photo, and null when he backed out entirely — three outcomes the
|
||||
/// caller can tell apart.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// How a stop ended, as this page reports it.
|
||||
///
|
||||
/// The page is reached by sliding **deliver**, so *delivered* is the answer it
|
||||
/// exists to collect. The other two are the answers a rider only has once he is
|
||||
/// standing at the door — and the door is the last place he should have to back
|
||||
/// out of a screen to record one.
|
||||
enum ProofOutcome {
|
||||
/// Handed over. Carries the photograph, when there is one.
|
||||
delivered,
|
||||
|
||||
/// Not this time — a return visit. The parcel stays on board.
|
||||
skipped,
|
||||
|
||||
/// It is not going to happen. The order ends here.
|
||||
cancelled,
|
||||
}
|
||||
|
||||
/// What [DeliveryProofPage] pops: the outcome, and the proof if the outcome
|
||||
/// was a hand-over.
|
||||
class ProofResult {
|
||||
final ProofOutcome outcome;
|
||||
|
||||
/// Where the photograph was saved. `''` means the rider completed without
|
||||
/// one — a camera that would not open, a customer who objected — which is a
|
||||
/// real answer and not a failure. Always `''` on the other two outcomes:
|
||||
/// there is nothing to photograph about a delivery that did not happen.
|
||||
final String proofPath;
|
||||
|
||||
const ProofResult(this.outcome, [this.proofPath = '']);
|
||||
}
|
||||
|
||||
class DeliveryProofPage extends StatefulWidget {
|
||||
/// Names the stop on the page, so the rider can see he is closing the right
|
||||
/// one before he commits.
|
||||
@@ -56,6 +89,55 @@ class DeliveryProofPage extends StatefulWidget {
|
||||
required this.orderId,
|
||||
});
|
||||
|
||||
/// The two answers a rider has when the hand-over did not happen.
|
||||
///
|
||||
/// ── Public, because this page is no longer the only door ──
|
||||
///
|
||||
/// It was private and reachable only from the link under **Mark as
|
||||
/// delivered**, which put Skip and Cancelled two taps below a control the
|
||||
/// rider has to slide *first*. A rider at a locked gate had to commit to
|
||||
/// delivering before he could say he had not.
|
||||
///
|
||||
/// The map sheet offers it beside its own slider now, so the answer is
|
||||
/// available at the moment the rider learns it. One sheet, two entry points:
|
||||
/// the wording, the order and the accents cannot drift between them, and a
|
||||
/// third outcome added here appears in both places at once.
|
||||
static Future<ProofOutcome?> askOutcome(BuildContext context) {
|
||||
return showMilerSheet<ProofOutcome>(
|
||||
context,
|
||||
builder: (ctx) => MilerSheetScaffold(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const MilerSheetHeader(
|
||||
title: 'What happened at this stop?',
|
||||
subtitle: 'This is recorded with the office straight away.',
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
MilerSheetChoiceRow(
|
||||
label: 'Skip — keep it on board and try again later',
|
||||
icon: LucideIcons.clock,
|
||||
accent: ColorConstants.warning,
|
||||
selected: false,
|
||||
showRule: false,
|
||||
onTap: () => Navigator.pop(ctx, ProofOutcome.skipped),
|
||||
),
|
||||
MilerSheetChoiceRow(
|
||||
label: 'Cancelled — this order will not be delivered',
|
||||
icon: LucideIcons.circleX,
|
||||
accent: ColorConstants.errorRed,
|
||||
selected: false,
|
||||
showRule: true,
|
||||
onTap: () => Navigator.pop(ctx, ProofOutcome.cancelled),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<DeliveryProofPage> createState() => _DeliveryProofPageState();
|
||||
}
|
||||
@@ -104,7 +186,27 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
|
||||
// A failed copy must not block the hand-over — the delivery is the thing
|
||||
// that matters, and the caller is told there is no proof rather than
|
||||
// being handed a path that resolves to nothing.
|
||||
Navigator.of(context).pop(saved ?? '');
|
||||
Navigator.of(context).pop(ProofResult(ProofOutcome.delivered, saved ?? ''));
|
||||
}
|
||||
|
||||
/// ── The two ways a stop ends without a hand-over ──
|
||||
///
|
||||
/// They used to live under the slide on the map sheet, which is one screen
|
||||
/// too early: a rider slides *deliver* because he intends to deliver, and he
|
||||
/// finds out he cannot at the door — on this page. Backing out of here to
|
||||
/// reach them meant the only way to report a failure was to abandon the
|
||||
/// screen that records outcomes.
|
||||
///
|
||||
/// Behind a link and on a sheet, not as two more buttons: both are
|
||||
/// irreversible and one of them ends the order, and this page is held
|
||||
/// one-handed at a doorstep. The happy path keeps the only filled control.
|
||||
Future<void> _cannotDeliver() async {
|
||||
if (_busy) return;
|
||||
final outcome = await DeliveryProofPage.askOutcome(context);
|
||||
if (!mounted || outcome == null) return;
|
||||
// No photograph travels with either of these: there is nothing to
|
||||
// photograph about a delivery that did not happen.
|
||||
Navigator.of(context).pop(ProofResult(outcome));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -120,12 +222,15 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h),
|
||||
// Bands, like the verification screen this page belongs
|
||||
// beside: full-bleed white on the page's ground, the gap
|
||||
// between them the only separator. See [MilerBand].
|
||||
padding: EdgeInsets.only(bottom: 8.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_who(),
|
||||
SizedBox(height: 16.h),
|
||||
_who(hasShot),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
hasShot ? _preview() : _empty(),
|
||||
],
|
||||
),
|
||||
@@ -138,10 +243,23 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Which stop this closes. Named, because the one mistake this page can
|
||||
/// enable is proving the wrong delivery.
|
||||
Widget _who() => GlassCard(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 14.h),
|
||||
/// Which stop this closes, and the way to re-take the photo.
|
||||
///
|
||||
/// ── Retake came up here from the foot of the page ──
|
||||
///
|
||||
/// It sat at the bottom as a full-width outlined button beside **Mark as
|
||||
/// delivered**, and that pairing was wrong twice over. It gave a *correction*
|
||||
/// the same weight and the same row as the act that closes the order, on a
|
||||
/// page whose one job is to close the order — so the rider's thumb had two
|
||||
/// equal targets to choose between at the moment he least wants a choice.
|
||||
/// And it put the control a screen away from the thing it controls: the photo
|
||||
/// is at the top, and the button that replaces it was at the bottom.
|
||||
///
|
||||
/// It is a small round control on this band now, next to the person being
|
||||
/// handed to. Reachable, obviously secondary, and beside the shot it retakes.
|
||||
/// The bottom of the page is one button again.
|
||||
Widget _who(bool hasShot) => MilerBand(
|
||||
padding: EdgeInsets.fromLTRB(MilerBand.pad, 14.h, MilerBand.pad, 14.h),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -149,7 +267,7 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
|
||||
height: 40.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.acceptGreen.withValues(alpha: 0.12),
|
||||
color: ColorConstants.tint(ColorConstants.acceptGreen, 0.12),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -189,108 +307,164 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Only once there is a shot to replace. Before that, taking the first
|
||||
// one is the *primary* thing to do and it has the big target in the
|
||||
// middle of the page — a second camera control up here would be the
|
||||
// same action offered twice.
|
||||
if (hasShot) ...[
|
||||
SizedBox(width: 10.w),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Retake the photo',
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: _busy ? null : _capture,
|
||||
child: Container(
|
||||
width: 46.w,
|
||||
height: 46.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.tint(ColorConstants.slateText, 0.07),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: ColorConstants.borderSubtle),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.rotateCw,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// The shot, at the size a judgement can be made from.
|
||||
Widget _preview() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: Image.file(
|
||||
File(_shotPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: ColorConstants.cardSurface,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: 32.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
Widget _preview() => MilerBand(
|
||||
padding: EdgeInsets.fromLTRB(MilerBand.pad, 16.h, MilerBand.pad, 16.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: Image.file(
|
||||
File(_shotPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: ColorConstants.cardSurface,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: 32.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.circleCheck,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Photo captured — it stays on this record',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
SizedBox(height: 12.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.circleCheck,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Photo captured — it stays on this record',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Before the first shot, or after the rider dismissed the camera.
|
||||
///
|
||||
/// ── The whole band opens the camera ──
|
||||
///
|
||||
/// It was inert artwork, and the way back to the camera was an **Open
|
||||
/// camera** button at the foot of the page. That button is gone — the foot of
|
||||
/// the page is one big *Mark as delivered* now — so this had to become the
|
||||
/// control rather than a picture of one, or a rider who dismissed the camera
|
||||
/// would have no way back to it at all.
|
||||
///
|
||||
/// It is the better target anyway: a whole band in the middle of the screen,
|
||||
/// where the eye already is, instead of a third of a row at the bottom.
|
||||
Widget _empty() => Semantics(
|
||||
button: !_cameraFailed,
|
||||
label: _cameraFailed
|
||||
? 'Camera unavailable. You can still complete this delivery.'
|
||||
: 'Take a photo of the handover',
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: _busy || _cameraFailed ? null : _capture,
|
||||
child: MilerBand(
|
||||
padding: EdgeInsets.fromLTRB(MilerBand.pad, 32.h, MilerBand.pad, 32.h),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 84.w,
|
||||
height: 84.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.tint(ColorConstants.primary, 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_cameraFailed ? LucideIcons.imageOff : LucideIcons.camera,
|
||||
size: 38.sp,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
_cameraFailed
|
||||
? 'Camera unavailable'
|
||||
: 'Take a photo of the handover',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
_cameraFailed
|
||||
? 'You can still complete this delivery without a photo.'
|
||||
: 'The parcel at the door, or with the person taking it.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// Before the first shot, or after the rider dismissed the camera.
|
||||
Widget _empty() => GlassCard(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 32.h, 20.w, 32.h),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72.w,
|
||||
height: 72.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_cameraFailed ? LucideIcons.imageOff : LucideIcons.camera,
|
||||
size: 32.sp,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
_cameraFailed ? 'Camera unavailable' : 'Take a photo of the handover',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
_cameraFailed
|
||||
? 'You can still complete this delivery without a photo.'
|
||||
: 'The parcel at the door, or with the person taking it.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -313,34 +487,56 @@ class _DeliveryProofPageState extends State<DeliveryProofPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MilerButton(
|
||||
label: hasShot ? 'Retake' : 'Open camera',
|
||||
icon: hasShot ? LucideIcons.rotateCw : LucideIcons.camera,
|
||||
variant: MilerButtonVariant.outlined,
|
||||
color: ColorConstants.slateText,
|
||||
height: ButtonSizes.primary,
|
||||
onPressed: _busy ? null : _capture,
|
||||
),
|
||||
// ── One button, and it is the size of the decision ──
|
||||
//
|
||||
// This row held two: an outlined **Retake** at one third and **Mark as
|
||||
// delivered** at two thirds. Retake has gone up beside the photo it
|
||||
// replaces (see [_who]), which leaves the act that closes the order
|
||||
// alone at the foot of the page with the whole width to itself.
|
||||
//
|
||||
// 72pt rather than the app's ordinary 56. It is the last thing a rider
|
||||
// does at a door, one-handed, often holding a parcel with the other —
|
||||
// and it is the only control here, so nothing is crowded by giving it
|
||||
// the room. The same height as the slide control on the map sheet, for
|
||||
// the same reason: see [_SlideAction.height].
|
||||
SizedBox(
|
||||
height: 72.h,
|
||||
child: MilerButton(
|
||||
label: 'Mark as delivered',
|
||||
icon: LucideIcons.circleCheck,
|
||||
color: ColorConstants.acceptGreen,
|
||||
height: 72.h,
|
||||
loading: _busy,
|
||||
onPressed: _busy ? null : _confirm,
|
||||
),
|
||||
),
|
||||
// ── The way out, under the way forward ──
|
||||
//
|
||||
// Quiet, centred, and second: a rider who cannot hand over is the
|
||||
// exception, and an exception must never be as easy to hit as the
|
||||
// happy path. It is also the only route to Skip and Cancelled in the
|
||||
// app, so it cannot be hidden either — a link under the button is
|
||||
// where both of those are true at once.
|
||||
SizedBox(height: 4.h),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _cannotDeliver,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: Size(double.infinity, ButtonSizes.minTapTarget),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: Text(
|
||||
"Couldn't hand it over?",
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
color: Narrative.inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: MilerButton(
|
||||
label: 'Mark as delivered',
|
||||
icon: LucideIcons.circleCheck,
|
||||
color: ColorConstants.acceptGreen,
|
||||
height: ButtonSizes.primary,
|
||||
loading: _busy,
|
||||
onPressed: _busy ? null : _confirm,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!hasShot) ...[
|
||||
SizedBox(height: 8.h),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'No photo attached to this delivery',
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -710,7 +710,7 @@ class _PickupsDoneState extends State<PickupsDone>
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'Head back to the hub to drop the parcels and hand in any cash '
|
||||
'Head back to base to drop the parcels and hand in any cash '
|
||||
'you collected.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
@@ -753,7 +753,7 @@ class _PickupsDoneState extends State<PickupsDone>
|
||||
// A milk run ends at the last door, not at a depot — see
|
||||
// [ServiceProfile.endsAtHub]. Capability, not screen position.
|
||||
: (ServiceProfile.active.endsAtHub
|
||||
? 'Return to hub'
|
||||
? 'Return to base'
|
||||
: 'Finish my round'),
|
||||
icon: hasNext
|
||||
? LucideIcons.arrowRight
|
||||
@@ -980,7 +980,7 @@ class _PickupsDoneState extends State<PickupsDone>
|
||||
label: hasNext
|
||||
? 'Move to next stop'
|
||||
: (ServiceProfile.active.endsAtHub
|
||||
? 'Return to hub'
|
||||
? 'Return to base'
|
||||
: 'Finish my round'),
|
||||
icon: hasNext
|
||||
? LucideIcons.arrowRight
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,12 @@ library;
|
||||
// what is removed here is this file's claim on them.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode, setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
@@ -30,6 +32,7 @@ import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/helpers/poll_policy.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/widgets/slide_action.dart';
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
@@ -1436,16 +1439,47 @@ class _MyPickupsState extends State<MyPickups>
|
||||
/// has nothing to offer. **No feature flag is mirrored here**, because a
|
||||
/// mirrored flag is a second source of truth that can be wrong.
|
||||
///
|
||||
/// The local sets are the fallback for a row that carries no consignment
|
||||
/// status yet — an older deployment, or a stop collected seconds ago that
|
||||
/// the queue has not caught up with.
|
||||
/// ── The rider's press decides this, not the server's rung ──
|
||||
///
|
||||
/// This read the consignment state first and only fell back to the local
|
||||
/// sets when the row carried none. That is the right instinct — a local flag
|
||||
/// must never outrank a server fact — and it produced the wrong answer for
|
||||
/// the case that is live on production today.
|
||||
///
|
||||
/// Two backend behaviours ship at once. With `MILER_COLLECTED_STATE_ENABLED`
|
||||
/// **on**, `pickup-complete` stops at `Collected_By_Miler` and this method's
|
||||
/// old reading is exactly right. With it **off** — compatibility mode, which
|
||||
/// is the default and what the app meets in production — `pickup-complete`
|
||||
/// releases the consignment itself, so a stop arrives on this tab already
|
||||
/// `Out_for_Delivery`, `needsRelease` answers false, and the round starts
|
||||
/// itself. The rider never sees PICKED and **Start delivery** never appears:
|
||||
/// he collects at the kitchen and the app has him on the road before he has
|
||||
/// left the counter.
|
||||
///
|
||||
/// So the two questions are separated, because they were never the same one:
|
||||
///
|
||||
/// ```
|
||||
/// may this be delivered? → the consignment. ConsignmentGate, deliver
|
||||
/// has he set off? → the rider. this method
|
||||
/// ```
|
||||
///
|
||||
/// Nothing here contradicts the server. A stop the app holds at PICKED is
|
||||
/// still `Out_for_Delivery` on the wire, `deliver` is still gated on the
|
||||
/// consignment's own state, and pressing **Start delivery** on a consignment
|
||||
/// that is already released costs no request at all — `releaseForDelivery`
|
||||
/// returns true on sight of a deliverable state. What changes is only which
|
||||
/// of the two facts the rider's card is showing him, and the answer is the
|
||||
/// one he can act on.
|
||||
///
|
||||
/// The consignment state stays the fallback for a stop this device has no
|
||||
/// record of — collected on another handset, or after a reinstall.
|
||||
bool _needsRelease(Map<String, dynamic> stop) {
|
||||
final state = consignmentStateFromRaw(stop['consignmentstatus']);
|
||||
if (state != ConsignmentState.unknown) {
|
||||
return state.needsRelease;
|
||||
}
|
||||
final id = MilkRun.idOf(stop);
|
||||
return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id);
|
||||
// He pressed it. Nothing outranks that.
|
||||
if (_outForDeliveryIds.contains(id)) return false;
|
||||
// He collected it here and has not pressed it.
|
||||
if (_collectedIds.contains(id)) return true;
|
||||
return consignmentStateFromRaw(stop['consignmentstatus']).needsRelease;
|
||||
}
|
||||
|
||||
/// True while the round is being marked started, so the bar cannot be
|
||||
@@ -1536,10 +1570,19 @@ class _MyPickupsState extends State<MyPickups>
|
||||
|
||||
final held = _unreleasedFailures;
|
||||
if (n == 0) {
|
||||
// One stop, one reason: say it. `releaseForDelivery` knows whether the
|
||||
// parcel is at a hub, missing its reference, refused, or genuinely
|
||||
// unreachable — see [lastReleaseFailure] — and "check your connection"
|
||||
// is true for only the last of those.
|
||||
//
|
||||
// A whole round that failed keeps the general wording: with several
|
||||
// stops there is no single reason to give, and the per-stop detail is in
|
||||
// the log.
|
||||
AppFeedback.errorGlobal(
|
||||
held == 1
|
||||
? 'Could not start that delivery. Check your connection and try '
|
||||
'again.'
|
||||
? (lastReleaseFailure ??
|
||||
'Could not start that delivery. Check your connection and '
|
||||
'try again.')
|
||||
: 'Could not start the round. Check your connection and try again.',
|
||||
);
|
||||
return;
|
||||
@@ -1751,23 +1794,46 @@ class _MyPickupsState extends State<MyPickups>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Opening a stop starts its leg, if the bar has not already ──
|
||||
// ── Opening a stop does NOT start its leg ──
|
||||
//
|
||||
// START DELIVERY is the normal way in and releases the whole load at once.
|
||||
// This is the other way in: a rider who taps a card without pressing the
|
||||
// bar is just as much on his way, and the stop has to be released before he
|
||||
// gets there — `deliver` refuses any consignment that is not
|
||||
// `Out_for_Delivery`, so opening one unreleased would walk him to a door
|
||||
// and fail him at it.
|
||||
// This used to call `startRound([item])` here — the same server release the
|
||||
// Start-ride slider makes — on the reasoning that a rider who taps a card
|
||||
// without pressing the bar is just as much on his way, and that `deliver`
|
||||
// refuses anything that is not `Out_for_Delivery`, so an unreleased stop
|
||||
// would walk him to a door and fail him at it.
|
||||
//
|
||||
// The same call the bar makes, for one order. [startRound] writes the local
|
||||
// marker only after the server agrees, so a failure here leaves the row
|
||||
// reading PICKED rather than lying about a round the hub has not started.
|
||||
if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) &&
|
||||
!_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) {
|
||||
await startRound([item]);
|
||||
if (!mounted) return;
|
||||
}
|
||||
// ── Why that is the production lifecycle bug, one tap later ──
|
||||
//
|
||||
// [MilkRun.navigatesToCustomer] is true the moment a stop is in
|
||||
// `_collectedIds`, which is the moment **Picked** succeeds. So the sequence
|
||||
// that matters is:
|
||||
//
|
||||
// ```
|
||||
// Home slide Picked → pickup-complete → Collected_By_Miler
|
||||
// Work tap the card → start-delivery → Out_for_Delivery ← here
|
||||
// Map slide Start ride (already released)
|
||||
// ```
|
||||
//
|
||||
// Tapping a card is not a statement that the rider has set off. It is how
|
||||
// he reads an address, checks a phone number, or looks at the route — and
|
||||
// it fired the one call that is supposed to be the slider's alone, so the
|
||||
// console went **Active** while the load was still on the counter and the
|
||||
// rider had not slid anything.
|
||||
//
|
||||
// It is invisible today only because `MILER_COLLECTED_STATE_ENABLED` is
|
||||
// off: the pivot has already released the consignment, so
|
||||
// `releaseForDelivery` sees a deliverable state and returns true without
|
||||
// spending a request. **The flag flip is what arms this.** Flipping it
|
||||
// without this change moves the early-Active bug rather than fixing it.
|
||||
//
|
||||
// The door is still covered, and by the branch written for it: the
|
||||
// `DeliverGate.needsRelease` case in `_closeDelivery` releases a stop the
|
||||
// rider never started, at the moment he actually hands over, then re-reads
|
||||
// the state before posting. That is the safety net this pre-release was
|
||||
// duplicating — one tap too early and on the wrong side of the boundary.
|
||||
//
|
||||
// See `MilerLifecycle`, and the regression tests in
|
||||
// `test/picked_does_not_release_test.dart`.
|
||||
|
||||
await openScreen(
|
||||
context,
|
||||
@@ -1962,10 +2028,25 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// `where` that used to sit here, re-admitting rows by raw status, is gone
|
||||
// with it: it was the hole an accepted-but-uncollected booking came
|
||||
// through, and a second expression of a fact that now has one.
|
||||
// ── What the rider has already written off today ──
|
||||
//
|
||||
// Read from the two stores that record a mutation *after* it came back
|
||||
// OK, so this is authoritative and not a display flag: `completed` holds
|
||||
// delivered and cancelled, `skipped` holds a parked attempt. Both are
|
||||
// day-stamped and scoped to this rider, tenant and line, and both survive
|
||||
// a cold start — which is what stops `/miler/bookings` re-admitting a
|
||||
// skipped stop on the next poll while the consignment is still
|
||||
// `Out_for_Delivery`. See [WorkBoundary.isClosed].
|
||||
final closedIds = <String>{
|
||||
...await getCompletedOrderIds(),
|
||||
...await getSkippedOrderIds(),
|
||||
};
|
||||
|
||||
var dedupedList = WorkBoundary.deliveryQueue(
|
||||
items.whereType<Map<String, dynamic>>(),
|
||||
collectedIds: collectedIds,
|
||||
acceptedIds: acceptedIds,
|
||||
closedIds: closedIds,
|
||||
);
|
||||
|
||||
// Drop stops the rider just finished. The backend may still return them
|
||||
@@ -1996,17 +2077,17 @@ class _MyPickupsState extends State<MyPickups>
|
||||
'${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}',
|
||||
);
|
||||
|
||||
// Re-apply locally-skipped orders so a skip survives polling even when the
|
||||
// backend hasn't recorded it yet (e.g. mock/offline data). Without this,
|
||||
// the 3s poll would overwrite the skip with the raw API status. Applied
|
||||
// before the filter below, because the filter now acts on it.
|
||||
if (_skippedOrdersCache.isNotEmpty) {
|
||||
for (final o in sortedList) {
|
||||
if (_skippedOrdersCache.containsKey(_getOrderKey(o))) {
|
||||
o['orderstatus'] = 'skipped';
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── The poll-time re-stamp is gone ──
|
||||
//
|
||||
// This used to walk the list writing `orderstatus = 'skipped'` onto every
|
||||
// row in the in-memory skip cache, so that the 3s poll could not revert a
|
||||
// skip the backend had not recorded. That was a display flag papering
|
||||
// over dual ownership: the row was still in the queue, still owned by
|
||||
// this tab, and merely wearing a different word.
|
||||
//
|
||||
// A skipped order no longer reaches this point — `closedIds` drops it at
|
||||
// the boundary, above both screens — so there is nothing left to stamp
|
||||
// and no second opinion to keep in sync. See [WorkBoundary.isClosed].
|
||||
|
||||
// Build the top list = the stops he is actually working.
|
||||
// - ACTIVE stops go to the banner, not the list.
|
||||
@@ -2895,14 +2976,20 @@ class _MyPickupsState extends State<MyPickups>
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
// The same white as Home, Activity and Account. This was `surface`
|
||||
// (#FCF9F8, a warm off-white); the difference is invisible under a full
|
||||
// list and obvious the moment the list is short, so the tab appeared to
|
||||
// change colour with the rider's workload.
|
||||
// The brand shows through the sheet's two top corners — see
|
||||
// [MilerSheet].
|
||||
// ── The corner has to have something behind it ──
|
||||
//
|
||||
// The sheet's two top corners were clipping onto a ground painted in
|
||||
// the same canvas the sheet itself uses, so the curve revealed the
|
||||
// page's own colour and was invisible: this tab read as a hard maroon
|
||||
// line above the queue while Home and Activity carried the shape. The
|
||||
// brand goes behind the sheet, which is what the other tabs do and
|
||||
// what makes the curve a curve. The page's own colour is the sheet's
|
||||
// (`MilerSurface.canvas`, one line below), not this.
|
||||
backgroundColor: ColorConstants.primary,
|
||||
appBar: MilerAppBar(
|
||||
// The bar's curve opens onto the sheet's canvas, not the app's
|
||||
// paper. See [milerBrandSurface].
|
||||
pageColor: MilerSurface.canvas,
|
||||
// ── The tab and the page it opens must agree ──
|
||||
//
|
||||
// This was the literal 'My Bookings', while the nav tab underneath it
|
||||
@@ -2936,322 +3023,342 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// [MilerSurface].
|
||||
body: MilerSheet(
|
||||
color: MilerSurface.canvas,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Three states, cross-faded: still asking, nothing to do, and the
|
||||
// queue itself. The middle one used to cover the first, and the
|
||||
// hand-off between any two of them used to be a single frame.
|
||||
SmoothSwap(
|
||||
child: _picked.isEmpty && !_firstLoadDone
|
||||
? const SkeletonList(key: ValueKey('bookings-loading'))
|
||||
: _picked.isEmpty && _fetchFailed
|
||||
? ErrorRetry(
|
||||
key: const ValueKey('bookings-error'),
|
||||
onRetry: () => _fetchPicked(force: true),
|
||||
)
|
||||
: _picked.isEmpty
|
||||
? SingleChildScrollView(
|
||||
key: const ValueKey('bookings-empty'),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.72,
|
||||
// ── The artwork is the whole empty state ──
|
||||
//
|
||||
// This was a [MilerEmptyState] drawing a headline and a
|
||||
// body line above the illustration — and the
|
||||
// illustration has both baked into it, in the same
|
||||
// words: "You're all caught up", then the line about
|
||||
// new jobs appearing automatically. Rendered together
|
||||
// the screen said each sentence twice, which reads as a
|
||||
// rendering fault rather than as one message. The panel
|
||||
// is the picture now, at a size worth looking at, and
|
||||
// it keeps its own copy.
|
||||
//
|
||||
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
|
||||
// folds away entirely in release, so nothing ships
|
||||
// underneath the art.
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/caught_up.png',
|
||||
width: 330.w,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
LucideIcons.truck,
|
||||
size: 64.sp,
|
||||
color: ColorConstants.borderStrong,
|
||||
// ── The sheet has to fill the body, not the queue ──
|
||||
//
|
||||
// A `Stack` hands its children loose constraints and then shrinks to
|
||||
// the tallest of them, so on a short queue — or the empty state —
|
||||
// the sheet ended where the content did and the rest of the body was
|
||||
// whatever the Scaffold was painted with. That was invisible while
|
||||
// the ground was the same canvas as the sheet; the moment the brand
|
||||
// went behind it (for the corners) it became a red band across the
|
||||
// bottom of the tab.
|
||||
child: SizedBox.expand(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Three states, cross-faded: still asking, nothing to do, and the
|
||||
// queue itself. The middle one used to cover the first, and the
|
||||
// hand-off between any two of them used to be a single frame.
|
||||
SmoothSwap(
|
||||
child: _picked.isEmpty && !_firstLoadDone
|
||||
? const SkeletonList(key: ValueKey('bookings-loading'))
|
||||
: _picked.isEmpty && _fetchFailed
|
||||
? ErrorRetry(
|
||||
key: const ValueKey('bookings-error'),
|
||||
onRetry: () => _fetchPicked(force: true),
|
||||
)
|
||||
: _picked.isEmpty
|
||||
? SingleChildScrollView(
|
||||
key: const ValueKey('bookings-empty'),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.72,
|
||||
// ── The artwork is the whole empty state ──
|
||||
//
|
||||
// This was a [MilerEmptyState] drawing a headline and a
|
||||
// body line above the illustration — and the
|
||||
// illustration has both baked into it, in the same
|
||||
// words: "You're all caught up", then the line about
|
||||
// new jobs appearing automatically. Rendered together
|
||||
// the screen said each sentence twice, which reads as a
|
||||
// rendering fault rather than as one message. The panel
|
||||
// is the picture now, at a size worth looking at, and
|
||||
// it keeps its own copy.
|
||||
//
|
||||
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
|
||||
// folds away entirely in release, so nothing ships
|
||||
// underneath the art.
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/caught_up.png',
|
||||
width: 330.w,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
LucideIcons.truck,
|
||||
size: 64.sp,
|
||||
color: ColorConstants.borderStrong,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_fetchDiagLine(),
|
||||
],
|
||||
_fetchDiagLine(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: const ValueKey('bookings-list'),
|
||||
children: [
|
||||
// No trip tabs here. Everything on this screen is already
|
||||
// accepted, so the only live question is which stop is
|
||||
// next — and that has one answer across the whole day.
|
||||
// See [_visibleStops].
|
||||
_buildTripRail(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchPicked,
|
||||
color: ColorConstants.primary,
|
||||
// ── An empty ROW list, not an empty stop list ──
|
||||
//
|
||||
// This asked `_visibleStops.isEmpty`, but the rows
|
||||
// are built from a filtered subset of it. When the
|
||||
// stops were all finished-but-not-yet-cleared, the
|
||||
// page took the populated branch and rendered a
|
||||
// `ListView` with zero children: a blank screen
|
||||
// under the rail, with no explanation and nothing
|
||||
// to pull on. Ask the list that is actually drawn.
|
||||
child: _queueRows().isEmpty
|
||||
? _buildTripTabEmptyState()
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
final rows = _queueRows();
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: 2.h,
|
||||
// Room for the live strip only when
|
||||
// one is actually drawn — see
|
||||
// [_liveOnThisScreen].
|
||||
// ── Room for whichever bar is
|
||||
// drawn ──
|
||||
//
|
||||
// A flat 80 was the live strip's
|
||||
// height, and the round bar is
|
||||
// taller: a caption, a primary
|
||||
// button and the nav-bar inset
|
||||
// under it. The last card sat
|
||||
// behind it with its controls
|
||||
// unreachable. Measured from the
|
||||
// pieces rather than guessed, so it
|
||||
// cannot drift when either grows.
|
||||
// ── One measured floor ──
|
||||
//
|
||||
// The unreleased branch was built
|
||||
// from its bar's own pieces; the
|
||||
// live branch was a flat `80.h`
|
||||
// that forgot the device inset
|
||||
// entirely, so on a gesture-nav
|
||||
// phone the last row of the run sat
|
||||
// behind the banner *and* behind
|
||||
// the navigation. Both surfaces now
|
||||
// state their own height and the
|
||||
// floor reserves whichever one is
|
||||
// actually showing, plus the inset.
|
||||
bottom: _bottomReservation(context),
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = rows[index];
|
||||
if (row.isHeading) {
|
||||
return _QueueHeading(
|
||||
label: row.heading!,
|
||||
count: row.count,
|
||||
// On the first heading only:
|
||||
// it describes the whole
|
||||
// queue, not the section.
|
||||
order: index == 0
|
||||
? _orderSource
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
final item = row.stop!;
|
||||
final orderId =
|
||||
(item['orderid'] ?? '')
|
||||
.toString();
|
||||
// Live metres + speed drive the
|
||||
// distance / ETA on every density;
|
||||
// RouteMetricsHelper picks the unit
|
||||
// and falls back to an urban average
|
||||
// when the GPS reading is unusable.
|
||||
final distanceMeters =
|
||||
RouteMetricsHelper.metersToStop(
|
||||
item,
|
||||
riderLat: _currentLocation
|
||||
?.latitude,
|
||||
riderLng: _currentLocation
|
||||
?.longitude,
|
||||
// The leg decides the end:
|
||||
// a delivery row measures
|
||||
// to the DOOR. It measured
|
||||
// to the kitchen, so the
|
||||
// figure grew as the rider
|
||||
// approached the customer.
|
||||
toDrop: MilkRun.workingKind(
|
||||
item,
|
||||
collectedIds: _collectedIds,
|
||||
).isDelivery,
|
||||
)
|
||||
: Column(
|
||||
key: const ValueKey('bookings-list'),
|
||||
children: [
|
||||
// No trip tabs here. Everything on this screen is already
|
||||
// accepted, so the only live question is which stop is
|
||||
// next — and that has one answer across the whole day.
|
||||
// See [_visibleStops].
|
||||
_buildTripRail(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchPicked,
|
||||
color: ColorConstants.primary,
|
||||
// ── An empty ROW list, not an empty stop list ──
|
||||
//
|
||||
// This asked `_visibleStops.isEmpty`, but the rows
|
||||
// are built from a filtered subset of it. When the
|
||||
// stops were all finished-but-not-yet-cleared, the
|
||||
// page took the populated branch and rendered a
|
||||
// `ListView` with zero children: a blank screen
|
||||
// under the rail, with no explanation and nothing
|
||||
// to pull on. Ask the list that is actually drawn.
|
||||
child: _queueRows().isEmpty
|
||||
? _buildTripTabEmptyState()
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
final rows = _queueRows();
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: 2.h,
|
||||
// Room for the live strip only when
|
||||
// one is actually drawn — see
|
||||
// [_liveOnThisScreen].
|
||||
// ── Room for whichever bar is
|
||||
// drawn ──
|
||||
//
|
||||
// A flat 80 was the live strip's
|
||||
// height, and the round bar is
|
||||
// taller: a caption, a primary
|
||||
// button and the nav-bar inset
|
||||
// under it. The last card sat
|
||||
// behind it with its controls
|
||||
// unreachable. Measured from the
|
||||
// pieces rather than guessed, so it
|
||||
// cannot drift when either grows.
|
||||
// ── One measured floor ──
|
||||
//
|
||||
// The unreleased branch was built
|
||||
// from its bar's own pieces; the
|
||||
// live branch was a flat `80.h`
|
||||
// that forgot the device inset
|
||||
// entirely, so on a gesture-nav
|
||||
// phone the last row of the run sat
|
||||
// behind the banner *and* behind
|
||||
// the navigation. Both surfaces now
|
||||
// state their own height and the
|
||||
// floor reserves whichever one is
|
||||
// actually showing, plus the inset.
|
||||
bottom: _bottomReservation(
|
||||
context,
|
||||
),
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = rows[index];
|
||||
if (row.isHeading) {
|
||||
return _QueueHeading(
|
||||
label: row.heading!,
|
||||
count: row.count,
|
||||
// On the first heading only:
|
||||
// it describes the whole
|
||||
// queue, not the section.
|
||||
order: index == 0
|
||||
? _orderSource
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
void open() => StopDetailSheet.show(
|
||||
context,
|
||||
stop: item,
|
||||
stopNumber: row.displayStep,
|
||||
// The pairing recorded at the
|
||||
// counter — same store as the
|
||||
// card, so the two agree.
|
||||
bag: row.bag,
|
||||
// The carried set — what turns
|
||||
// a raw pickup row into the
|
||||
// delivery leg the sheet must
|
||||
// describe.
|
||||
collectedIds: _collectedIds,
|
||||
riderLat:
|
||||
_currentLocation?.latitude,
|
||||
riderLng:
|
||||
_currentLocation?.longitude,
|
||||
);
|
||||
final item = row.stop!;
|
||||
final orderId =
|
||||
(item['orderid'] ?? '')
|
||||
.toString();
|
||||
// Live metres + speed drive the
|
||||
// distance / ETA on every density;
|
||||
// RouteMetricsHelper picks the unit
|
||||
// and falls back to an urban average
|
||||
// when the GPS reading is unusable.
|
||||
final distanceMeters =
|
||||
RouteMetricsHelper.metersToStop(
|
||||
item,
|
||||
riderLat: _currentLocation
|
||||
?.latitude,
|
||||
riderLng: _currentLocation
|
||||
?.longitude,
|
||||
// The leg decides the end:
|
||||
// a delivery row measures
|
||||
// to the DOOR. It measured
|
||||
// to the kitchen, so the
|
||||
// figure grew as the rider
|
||||
// approached the customer.
|
||||
toDrop: MilkRun.workingKind(
|
||||
item,
|
||||
collectedIds:
|
||||
_collectedIds,
|
||||
).isDelivery,
|
||||
);
|
||||
|
||||
// ── One card shape per density ──
|
||||
//
|
||||
// Not one card dimmed four ways. See
|
||||
// [QueueDensity] for why the live stop
|
||||
// is physically bigger rather than
|
||||
// merely brighter.
|
||||
final Widget child = switch (row
|
||||
.density) {
|
||||
QueueDensity.now => PickupCard(
|
||||
key: ValueKey(
|
||||
'pickup_$orderId',
|
||||
),
|
||||
item: item,
|
||||
displayStep: row.displayStep,
|
||||
distanceMeters: distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
void
|
||||
open() => StopDetailSheet.show(
|
||||
context,
|
||||
stop: item,
|
||||
stopNumber: row.displayStep,
|
||||
// The pairing recorded at the
|
||||
// counter — same store as the
|
||||
// card, so the two agree.
|
||||
bag: row.bag,
|
||||
// The carried set — what turns
|
||||
// a raw pickup row into the
|
||||
// delivery leg the sheet must
|
||||
// describe.
|
||||
collectedIds: _collectedIds,
|
||||
outForDeliveryIds:
|
||||
_outForDeliveryIds,
|
||||
),
|
||||
QueueDensity.next => _NextCard(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
distanceMeters: distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
onOpen: open,
|
||||
),
|
||||
QueueDensity.later => _LaterRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
distanceMeters: distanceMeters,
|
||||
onOpen: open,
|
||||
),
|
||||
QueueDensity.skipped =>
|
||||
_SkippedRow(
|
||||
riderLat:
|
||||
_currentLocation?.latitude,
|
||||
riderLng:
|
||||
_currentLocation?.longitude,
|
||||
);
|
||||
|
||||
// ── One card shape per density ──
|
||||
//
|
||||
// Not one card dimmed four ways. See
|
||||
// [QueueDensity] for why the live stop
|
||||
// is physically bigger rather than
|
||||
// merely brighter.
|
||||
final Widget child = switch (row
|
||||
.density) {
|
||||
QueueDensity.now => PickupCard(
|
||||
key: ValueKey(
|
||||
'pickup_$orderId',
|
||||
),
|
||||
item: item,
|
||||
displayStep: row.displayStep,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
bag: row.bag,
|
||||
collectedIds: _collectedIds,
|
||||
outForDeliveryIds:
|
||||
_outForDeliveryIds,
|
||||
),
|
||||
QueueDensity.next => _NextCard(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
reason: row.reason,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
onOpen: open,
|
||||
onResume: () =>
|
||||
_resumeSkipped(item),
|
||||
),
|
||||
};
|
||||
QueueDensity.later => _LaterRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
onOpen: open,
|
||||
),
|
||||
QueueDensity.skipped =>
|
||||
_SkippedRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
reason: row.reason,
|
||||
onOpen: open,
|
||||
onResume: () =>
|
||||
_resumeSkipped(item),
|
||||
),
|
||||
};
|
||||
|
||||
return Reveal(
|
||||
key: ValueKey('reveal_$orderId'),
|
||||
delay: staggerDelay(index),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
return Reveal(
|
||||
key: ValueKey(
|
||||
'reveal_$orderId',
|
||||
),
|
||||
delay: staggerDelay(index),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// The live strip, shared with Home. It places itself clear of the
|
||||
// floating nav bar, so it is pinned flush here and left to it.
|
||||
//
|
||||
// Only for a stop this screen can actually work. On a kitchen line
|
||||
// a collection is Home's, in bulk, so a strip here offering to
|
||||
// "continue" one was an invitation into a flow the rider is not
|
||||
// supposed to be in — and the way most riders found it. See
|
||||
// [_liveOnThisScreen].
|
||||
// ── The hinge of the day, at the foot of the tab that holds the
|
||||
// load ──
|
||||
//
|
||||
// It shares this slot with the live strip and yields to it: once a
|
||||
// stop is running, "continue that stop" is the only thing the rider
|
||||
// wants from the bottom of this screen, and a second bar under his
|
||||
// thumb offering to start something else is how the wrong one gets
|
||||
// pressed. See [startRound].
|
||||
// ── One contextual slot, and the workflow advances through it ──
|
||||
//
|
||||
// These were two `if`s over the same corner. Logically exclusive
|
||||
// already — a live stop suppresses the start bar, because a
|
||||
// second bar under the rider's thumb offering to start something
|
||||
// else is how the wrong one gets pressed — but they *swapped* by
|
||||
// one disappearing and the other appearing in the same frame,
|
||||
// which reads as the screen glitching rather than as **Start
|
||||
// delivery → Continue this stop**.
|
||||
//
|
||||
// One switcher, one child at a time, on the app's own state
|
||||
// duration. Keyed by which surface it is rather than by its
|
||||
// contents, so a poll that returns the same live stop does not
|
||||
// replay the transition.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: DesignConstants.motionState,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _liveOnThisScreen.isNotEmpty
|
||||
? ActivePickupBanner(
|
||||
key: const ValueKey('live'),
|
||||
activePickups: _liveOnThisScreen,
|
||||
onTap: (pickup) async {
|
||||
await startPickup(pickup);
|
||||
// Refresh after returning from map screen
|
||||
if (mounted) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
_fetchPicked();
|
||||
}
|
||||
},
|
||||
)
|
||||
: _unreleased.isNotEmpty
|
||||
? StartRoundBar(
|
||||
key: const ValueKey('start-round'),
|
||||
count: _unreleased.length,
|
||||
busy: _starting,
|
||||
onPressed: _startWholeRound,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('none')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// The live strip, shared with Home. It places itself clear of the
|
||||
// floating nav bar, so it is pinned flush here and left to it.
|
||||
//
|
||||
// Only for a stop this screen can actually work. On a kitchen line
|
||||
// a collection is Home's, in bulk, so a strip here offering to
|
||||
// "continue" one was an invitation into a flow the rider is not
|
||||
// supposed to be in — and the way most riders found it. See
|
||||
// [_liveOnThisScreen].
|
||||
// ── The hinge of the day, at the foot of the tab that holds the
|
||||
// load ──
|
||||
//
|
||||
// It shares this slot with the live strip and yields to it: once a
|
||||
// stop is running, "continue that stop" is the only thing the rider
|
||||
// wants from the bottom of this screen, and a second bar under his
|
||||
// thumb offering to start something else is how the wrong one gets
|
||||
// pressed. See [startRound].
|
||||
// ── One contextual slot, and the workflow advances through it ──
|
||||
//
|
||||
// These were two `if`s over the same corner. Logically exclusive
|
||||
// already — a live stop suppresses the start bar, because a
|
||||
// second bar under the rider's thumb offering to start something
|
||||
// else is how the wrong one gets pressed — but they *swapped* by
|
||||
// one disappearing and the other appearing in the same frame,
|
||||
// which reads as the screen glitching rather than as **Start
|
||||
// delivery → Continue this stop**.
|
||||
//
|
||||
// One switcher, one child at a time, on the app's own state
|
||||
// duration. Keyed by which surface it is rather than by its
|
||||
// contents, so a poll that returns the same live stop does not
|
||||
// replay the transition.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: DesignConstants.motionState,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _liveOnThisScreen.isNotEmpty
|
||||
? ActivePickupBanner(
|
||||
key: const ValueKey('live'),
|
||||
activePickups: _liveOnThisScreen,
|
||||
onTap: (pickup) async {
|
||||
await startPickup(pickup);
|
||||
// Refresh after returning from map screen
|
||||
if (mounted) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
_fetchPicked();
|
||||
}
|
||||
},
|
||||
)
|
||||
: _unreleased.isNotEmpty
|
||||
? StartRoundBar(
|
||||
key: const ValueKey('start-round'),
|
||||
count: _unreleased.length,
|
||||
busy: _starting,
|
||||
onPressed: _startWholeRound,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('none')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -683,7 +683,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
if (payRes?['status'] != true && mounted) {
|
||||
AppFeedback.error(
|
||||
context,
|
||||
"Payment recorded on your phone but not with the hub — tell them "
|
||||
"Payment recorded on your phone but not with your office — tell them "
|
||||
"you collected ₹${_paidAmount.toStringAsFixed(0)}",
|
||||
);
|
||||
}
|
||||
@@ -1285,19 +1285,27 @@ class _StopBrief extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// ── The avatar is a disc, and it is neutral ──
|
||||
//
|
||||
// A tinted rounded square in the leg's accent put a third coloured
|
||||
// object on a sheet that already has a state pill and a coloured
|
||||
// rail, and the colour said nothing the two of them had not
|
||||
// already said. Initials are an identity, not a state: they take
|
||||
// the app's quiet inset tone, and the round shape is what tells
|
||||
// the eye it stands for a person.
|
||||
Container(
|
||||
width: 40.w,
|
||||
height: 40.w,
|
||||
width: 42.w,
|
||||
height: 42.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: kind.accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
decoration: const BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: kind.accent,
|
||||
fontSize: 13.5.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -1355,8 +1363,12 @@ class _StopBrief extends StatelessWidget {
|
||||
// Aligned to the avatar's column so the two rows read as one block
|
||||
// rather than as two things that happen to be near each other.
|
||||
SizedBox(
|
||||
width: 40.w,
|
||||
child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent),
|
||||
width: 42.w,
|
||||
child: Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
@@ -1364,12 +1376,20 @@ class _StopBrief extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
// ── The destination label is brand ──
|
||||
//
|
||||
// It took the leg's accent — blue on a delivery — which is
|
||||
// a *classification* colour, and this line is not
|
||||
// classifying anything: the rider is already on the screen
|
||||
// for this stop. What it is doing is pointing at where he
|
||||
// has to go, and pointing is what the brand does on this
|
||||
// sheet (the rail, the pin beside this label).
|
||||
kind.locationLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: kind.accent,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -209,7 +209,7 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
|
||||
_quoteError = quote.found
|
||||
? null
|
||||
: 'No price is configured for this weight and route. '
|
||||
'Call the hub before collecting.';
|
||||
'Call your office before collecting.';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,14 +297,22 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
|
||||
// ── The bands carry the gutter, the page does not ──
|
||||
//
|
||||
// Every child below is full-bleed and pads its own content by
|
||||
// [_bandPad]. The page used to hold a 16pt margin and give each
|
||||
// card a border and a radius on top of it, which spent 32 of a
|
||||
// 390pt phone's width on two strips of grey either side of a
|
||||
// form whose fields are addresses. The ground showing between
|
||||
// two bands separates them just as well and costs nothing.
|
||||
padding: EdgeInsets.only(bottom: 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_journeyCard(),
|
||||
SizedBox(height: 14.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_parcelCard(),
|
||||
SizedBox(height: 14.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_quotePanel(),
|
||||
],
|
||||
),
|
||||
@@ -360,13 +368,7 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
|
||||
/// and it is the shape because it is read in one glance.
|
||||
/// ─────────────────────────────────────────────────────────────────────
|
||||
Widget _journeyCard() {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
|
||||
),
|
||||
return MilerBand(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -508,14 +510,14 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
|
||||
/// One surface rather than a heading and three loose blocks, for the same
|
||||
/// reason as the journey above: these three answers are one description of
|
||||
/// one parcel, and the price is computed from all three together.
|
||||
/// The inset a full-bleed band keeps around its content. The app's own, so
|
||||
/// this page and the verification screen that hands off to it line up at the
|
||||
/// same left margin. See [MilerBand].
|
||||
double get _bandPad => MilerBand.pad;
|
||||
|
||||
Widget _parcelCard() {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(14.w, 15.h, 14.w, 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
|
||||
),
|
||||
return MilerBand(
|
||||
padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 16.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -588,21 +590,14 @@ class _ShipmentCapturePageState extends State<ShipmentCapturePage> {
|
||||
final quote = _quote;
|
||||
final priced = quote != null && !_quoting && _quoteError == null;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: priced
|
||||
? ColorConstants.acceptGreen.withValues(alpha: 0.06)
|
||||
: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(
|
||||
color: priced
|
||||
? ColorConstants.acceptGreen.withValues(alpha: 0.28)
|
||||
: ColorConstants.borderSubtle,
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
// A priced band goes pale green and takes the rail; an unpriced one stays
|
||||
// white. Both fills are opaque colours rather than the accent laid over the
|
||||
// page — see [ColorConstants.tint].
|
||||
return MilerBand(
|
||||
color: priced
|
||||
? ColorConstants.tint(ColorConstants.acceptGreen, 0.06)
|
||||
: ColorConstants.pureSurface,
|
||||
rail: priced ? ColorConstants.acceptGreen : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -207,25 +207,29 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h),
|
||||
// Full-bleed bands, like the verification screen this page is the
|
||||
// end of. The section headers moved inside the band they head — a
|
||||
// heading on the page and a card under it puts two left margins on
|
||||
// one screen, which is the thing bands exist to stop. See
|
||||
// [MilerBand].
|
||||
padding: EdgeInsets.only(bottom: 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_routeCard(),
|
||||
SizedBox(height: 14.h),
|
||||
const SectionHeader(title: 'Shipment'),
|
||||
SizedBox(height: 8.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_shipmentCard(),
|
||||
SizedBox(height: 14.h),
|
||||
const SectionHeader(title: 'Payment'),
|
||||
SizedBox(height: 8.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_paymentCard(),
|
||||
if (_error != null) ...[
|
||||
SizedBox(height: 14.h),
|
||||
InfoBanner(
|
||||
icon: LucideIcons.circleAlert,
|
||||
text: _error!,
|
||||
color: ColorConstants.errorRed,
|
||||
SizedBox(height: MilerBand.gap),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: MilerBand.pad),
|
||||
child: InfoBanner(
|
||||
icon: LucideIcons.circleAlert,
|
||||
text: _error!,
|
||||
color: ColorConstants.errorRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -239,7 +243,7 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
|
||||
|
||||
/// From → to, drawn as a journey rather than two labelled fields.
|
||||
Widget _routeCard() {
|
||||
return AppCard(
|
||||
return MilerBand(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -345,9 +349,12 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
|
||||
final weight = widget.shipment['weight'];
|
||||
final km = widget.shipment['approxkm'];
|
||||
|
||||
return AppCard(
|
||||
return MilerBand(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader(title: 'Shipment'),
|
||||
SizedBox(height: 4.h),
|
||||
_row('Weight', weight == null ? '—' : '$weight kg'),
|
||||
_divider(),
|
||||
_row('Category', _s(widget.shipment['category'])),
|
||||
@@ -374,9 +381,12 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
|
||||
}
|
||||
|
||||
Widget _paymentCard() {
|
||||
return AppCard(
|
||||
return MilerBand(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader(title: 'Payment'),
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
children: [
|
||||
for (final m in ShipmentPayMethod.values) ...[
|
||||
@@ -425,7 +435,7 @@ class _ShipmentReviewPageState extends State<ShipmentReviewPage>
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? ColorConstants.primary.withValues(alpha: 0.08)
|
||||
? ColorConstants.tint(ColorConstants.primary, 0.08)
|
||||
: ColorConstants.daylightSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
border: Border.all(
|
||||
|
||||
@@ -299,7 +299,7 @@ Future<void> _showMyOptionsSheet(
|
||||
// rather than a variant.
|
||||
const MilerSheetHeader(
|
||||
title: 'Skip this pickup',
|
||||
subtitle: 'Tell us why, so the hub can reassign it.',
|
||||
subtitle: 'Tell us why, so your office can reassign it.',
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
// ── Which pickup ──
|
||||
|
||||
@@ -427,18 +427,25 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _back();
|
||||
},
|
||||
// ── White page, tinted panels ──
|
||||
// ── Grey page, white bands ──
|
||||
//
|
||||
// Dropping the borders left the sections floating: a white card on a
|
||||
// near-white page has no edge at all, so five panels read as one long
|
||||
// undifferentiated form. Inverting it is what the Home stop card already
|
||||
// does — the page is white and the card carries the fill — and it is the
|
||||
// only way to separate surfaces without drawing a line around each one.
|
||||
// See [ColorConstants.cardSurface] for why the fill sits 9.4% off white
|
||||
// rather than the ~3% a "subtle" grey would give: this screen is filled
|
||||
// in outdoors.
|
||||
// It was the other way round — a white page carrying tinted panels — and
|
||||
// that inversion was itself a fix for panels that would not separate.
|
||||
// Both readings work; this one is taken because of what it does to the
|
||||
// *width*. A tinted panel has to be inset from the page or its fill runs
|
||||
// into the screen edge, so every section sat inside a 20pt gutter and the
|
||||
// page spent 40 of its 390 points on two strips of white that said
|
||||
// nothing. A white band on a grey ground has no such problem: it can run
|
||||
// the full width, because the ground showing above and below it is what
|
||||
// separates one section from the next.
|
||||
//
|
||||
// So the sections are full-bleed now and the gutter lives *inside* them,
|
||||
// where it is padding around content rather than margin around a box.
|
||||
// Same air next to the words, ~40pt more of it for the words themselves —
|
||||
// which on this screen is the difference between an address on two lines
|
||||
// and an address on three.
|
||||
child: Scaffold(
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
backgroundColor: ColorConstants.daylightSurface,
|
||||
// ── The bar breathes, and its title is a title ──
|
||||
//
|
||||
// 17sp jammed against the status bar is a system default, not a
|
||||
@@ -462,15 +469,17 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 24.h),
|
||||
// No horizontal padding: every child of this column is a
|
||||
// full-bleed band that carries its own. See the note on the
|
||||
// scaffold's ground.
|
||||
padding: EdgeInsets.only(bottom: 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stopSummary(),
|
||||
// No rule under the summary any more: the steps below
|
||||
// are panels with their own edges, and a divider between a
|
||||
// card and a card is a line drawn between two boundaries
|
||||
// that already exist.
|
||||
// are bands with the page's ground between them, and a
|
||||
// divider between two boundaries is a third boundary.
|
||||
if (_hasReview && _step >= _legs.length) ...[
|
||||
SizedBox(height: 6.h),
|
||||
_reviewStep(),
|
||||
@@ -639,136 +648,155 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
// first thing on the page, so every leg opened by drawing the eye to the one
|
||||
// block that needs nothing done to it. Unboxed it reads as a heading, which
|
||||
// is what it is.
|
||||
/// The stop, as the band the form is being filled in *about*.
|
||||
///
|
||||
/// Full-bleed white, like every section under it, so the page reads as one
|
||||
/// stack of bands rather than a heading floating over a stack of boxes.
|
||||
Widget _stopSummary() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Both halves give ground, neither is cut off ──
|
||||
//
|
||||
// A fixed badge, a `Spacer` and a fixed amount: the row was wider
|
||||
// than a 360pt phone from 1.3x system text upward, and the rider who
|
||||
// has turned his font up is the one who most needs to read "₹2450 to
|
||||
// collect". Flexible on both sides costs nothing at normal size —
|
||||
// each still takes its natural width — and lets the longer one
|
||||
// ellipsise instead of striping the card black and yellow.
|
||||
Row(
|
||||
children: [
|
||||
// The stop type as a dotted label rather than a filled badge. It
|
||||
// is an attribute of the name below it, and a saturated chip made
|
||||
// it the loudest thing on a screen whose subject is the form.
|
||||
Container(
|
||||
width: 6.w,
|
||||
height: 6.w,
|
||||
margin: EdgeInsets.only(right: 8.w),
|
||||
decoration: BoxDecoration(
|
||||
color: _kind.accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_kind.titleLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
return MilerBand(
|
||||
padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Both halves give ground, neither is cut off ──
|
||||
//
|
||||
// A fixed badge, a `Spacer` and a fixed amount: the row was wider
|
||||
// than a 360pt phone from 1.3x system text upward, and the rider who
|
||||
// has turned his font up is the one who most needs to read "₹2450 to
|
||||
// collect". Flexible on both sides costs nothing at normal size —
|
||||
// each still takes its natural width — and lets the longer one
|
||||
// ellipsise instead of striping the card black and yellow.
|
||||
Row(
|
||||
children: [
|
||||
// The stop type as a dotted label rather than a filled badge. It
|
||||
// is an attribute of the name below it, and a saturated chip made
|
||||
// it the loudest thing on a screen whose subject is the form.
|
||||
Container(
|
||||
width: 6.w,
|
||||
height: 6.w,
|
||||
margin: EdgeInsets.only(right: 8.w),
|
||||
decoration: BoxDecoration(
|
||||
color: _kind.accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_cash > 0)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'₹${_cash.toStringAsFixed(0)} to collect',
|
||||
_kind.titleLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.acceptGreen,
|
||||
color: _kind.accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
_customerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
const Spacer(),
|
||||
if (_cash > 0)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'₹${_cash.toStringAsFixed(0)} to collect',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.acceptGreen,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
// The pin is doing work, not decorating: this line and the customer
|
||||
// name above it are both grey-on-white running text at a glance, and
|
||||
// the glyph is what says which of the two is the place.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 2.h, right: 6.w),
|
||||
child: Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 14.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
_customerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_address,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.micro.copyWith(
|
||||
fontSize: 13.sp,
|
||||
height: 1.4,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
// The pin is doing work, not decorating: this line and the customer
|
||||
// name above it are both grey-on-white running text at a glance, and
|
||||
// the glyph is what says which of the two is the place.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 2.h, right: 6.w),
|
||||
child: Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 14.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// Wrap rather than Row: on a combined stop the two pills together are
|
||||
// wider than the page once the system font is up, and the second one
|
||||
// belongs on its own line rather than off the edge of the screen.
|
||||
Wrap(
|
||||
spacing: 7.w,
|
||||
runSpacing: 7.h,
|
||||
children: [
|
||||
if (_expectedDelivery > 0)
|
||||
_loadPill(
|
||||
LucideIcons.truck,
|
||||
'Hand over $_expectedDelivery',
|
||||
ColorConstants.deliveryAccent,
|
||||
Expanded(
|
||||
child: Text(
|
||||
_address,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.micro.copyWith(
|
||||
fontSize: 13.sp,
|
||||
height: 1.4,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_expectedPickup > 0)
|
||||
_loadPill(
|
||||
LucideIcons.package,
|
||||
'Collect $_expectedPickup',
|
||||
ColorConstants.pickupAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// Wrap rather than Row: on a combined stop the two pills together are
|
||||
// wider than the page once the system font is up, and the second one
|
||||
// belongs on its own line rather than off the edge of the screen.
|
||||
Wrap(
|
||||
spacing: 7.w,
|
||||
runSpacing: 7.h,
|
||||
children: [
|
||||
if (_expectedDelivery > 0)
|
||||
_loadPill(
|
||||
LucideIcons.truck,
|
||||
'Hand over $_expectedDelivery',
|
||||
ColorConstants.deliveryAccent,
|
||||
),
|
||||
if (_expectedPickup > 0)
|
||||
_loadPill(
|
||||
LucideIcons.package,
|
||||
'Collect $_expectedPickup',
|
||||
ColorConstants.pickupAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A finished band's ground: the accept green at 5.5%, resolved onto white
|
||||
/// so it is an ordinary opaque colour rather than a wash that changes with
|
||||
/// what is behind it.
|
||||
static final Color _doneFill = ColorConstants.tint(
|
||||
ColorConstants.acceptGreen,
|
||||
0.055,
|
||||
);
|
||||
|
||||
/// The inset a band keeps around its content — the app's own, so this page
|
||||
/// and the shipment desk it hands off to line up at the same left margin.
|
||||
double get _bandPad => MilerBand.pad;
|
||||
|
||||
Widget _loadPill(IconData icon, String label, Color accent) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 6.h),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
color: ColorConstants.tint(accent, 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Row(
|
||||
@@ -897,7 +925,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ticked
|
||||
? ColorConstants.acceptGreen.withValues(alpha: 0.10)
|
||||
? ColorConstants.tint(ColorConstants.acceptGreen, 0.10)
|
||||
: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
// The tick is the whole point of this control, so the untouched state
|
||||
@@ -1167,10 +1195,12 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// The heading lives on the page, not in a card — this is the screen
|
||||
// talking, not a section of it.
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 0),
|
||||
// The heading is the screen talking rather than a section of it, but it
|
||||
// is still a band: with full-bleed sections under it, a heading inset
|
||||
// by its own margin is the one thing on the page whose left edge does
|
||||
// not line up with everything else.
|
||||
MilerBand(
|
||||
padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1219,7 +1249,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
|
||||
if (_kind.hasDelivery)
|
||||
_reviewLeg(
|
||||
@@ -1251,7 +1281,8 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
],
|
||||
),
|
||||
|
||||
if (_kind.hasDelivery && _kind.hasPickup) SizedBox(height: 12.h),
|
||||
if (_kind.hasDelivery && _kind.hasPickup)
|
||||
SizedBox(height: MilerBand.gap),
|
||||
|
||||
if (_kind.hasPickup)
|
||||
_reviewLeg(
|
||||
@@ -1296,7 +1327,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
),
|
||||
|
||||
if (_cash > 0) ...[
|
||||
SizedBox(height: 12.h),
|
||||
SizedBox(height: MilerBand.gap),
|
||||
_notice(
|
||||
'Collect ₹${_cash.toStringAsFixed(0)} from the customer on the '
|
||||
'next screen',
|
||||
@@ -1326,13 +1357,10 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
required bool done,
|
||||
File? photo,
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.cardSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
// White, and full-bleed like every other band. It was a tinted rounded card,
|
||||
// which on a grey page is a box on a box: the ground between two bands
|
||||
// already says these are two legs of the stop.
|
||||
return MilerBand(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1375,8 +1403,8 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
color: ColorConstants.tint(color, 0.12),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -1425,7 +1453,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.warning.withValues(alpha: 0.10),
|
||||
color: ColorConstants.tint(ColorConstants.warning, 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Row(
|
||||
@@ -1904,26 +1932,19 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
final accent = done ? ColorConstants.acceptGreen : _legAccent;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 14.h),
|
||||
child: AnimatedContainer(
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOut,
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.fromLTRB(16.w, 15.h, 16.w, 16.h),
|
||||
decoration: BoxDecoration(
|
||||
// Done recedes; outstanding keeps the page. Neither is a border
|
||||
// colour — a filled state survives sunlight and a 1px edge does not.
|
||||
color: done
|
||||
? ColorConstants.acceptGreen.withValues(alpha: 0.055)
|
||||
: ColorConstants.daylightSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(
|
||||
color: done
|
||||
? ColorConstants.acceptGreen.withValues(alpha: 0.22)
|
||||
: ColorConstants.borderSubtle,
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(top: MilerBand.gap),
|
||||
// ── State in the fill, and the fill is a colour rather than a wash ──
|
||||
//
|
||||
// Done recedes and outstanding keeps the page; that is the right property
|
||||
// to carry it, because a fill is the one thing readable at arm's length
|
||||
// in sun. Both states are opaque colours rather than the accent laid over
|
||||
// whatever is behind (see [ColorConstants.tint]), and the finished band
|
||||
// adds the rail, which is what replaced the tinted outline these sections
|
||||
// used to close with. See [MilerBand].
|
||||
child: MilerBand(
|
||||
color: done ? _doneFill : ColorConstants.pureSurface,
|
||||
rail: done ? ColorConstants.acceptGreen : null,
|
||||
padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 17.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1935,7 +1956,11 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
height: 30.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.11),
|
||||
color: ColorConstants.tint(
|
||||
accent,
|
||||
0.11,
|
||||
on: done ? _doneFill : ColorConstants.pureSurface,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
@@ -2020,7 +2045,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.warning.withValues(alpha: 0.10),
|
||||
color: ColorConstants.tint(ColorConstants.warning, 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Text(
|
||||
@@ -2274,7 +2299,7 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
padding: EdgeInsets.symmetric(vertical: 30.h),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.06),
|
||||
color: ColorConstants.tint(accent, 0.06),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.30), width: 1.4),
|
||||
),
|
||||
@@ -2302,9 +2327,14 @@ class _StopVerificationPageState extends State<StopVerificationPage> {
|
||||
Widget _notice(String text, Color accent) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: EdgeInsets.symmetric(horizontal: _bandPad),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
color: ColorConstants.tint(
|
||||
accent,
|
||||
0.10,
|
||||
on: ColorConstants.daylightSurface,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:miler/views/Dashboard/profile/informations/order_alert_sound.dar
|
||||
import 'package:miler/views/Dashboard/summary/summary.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
@@ -163,13 +164,26 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
// rows on the bare background, so the whole tab read a shade dirtier than
|
||||
// the three either side of it. Four tabs of one app should not disagree
|
||||
// about what colour the paper is.
|
||||
// The brand shows through the sheet's two top corners — see [MilerSheet].
|
||||
// ── The corner has to have something behind it ──
|
||||
//
|
||||
// Account had the sheet but not the shape: its two top corners clipped
|
||||
// onto a white ground, so the curve revealed white and read as a
|
||||
// rounding artefact rather than as the brand coming through. The brand
|
||||
// goes behind the sheet, the same as the other three tabs; the page's
|
||||
// own colour is the sheet's [MilerSurface.canvas].
|
||||
backgroundColor: ColorConstants.primary,
|
||||
// Account was the odd tab out: no top bar at all, just a 28sp inline
|
||||
// heading scrolling away with the content, while the other three sat
|
||||
// under a fixed branded bar. Four tabs of one app should not disagree
|
||||
// about where the page title lives.
|
||||
appBar: MilerAppBar(title: 'Account', trailing: _bell()),
|
||||
appBar: MilerAppBar(
|
||||
title: 'Account',
|
||||
trailing: _bell(),
|
||||
// The bar's curve opens onto whatever is directly under it, and under
|
||||
// this bar is the sheet's canvas rather than the app's paper. See
|
||||
// [milerBrandSurface].
|
||||
pageColor: Narrative.canvas,
|
||||
),
|
||||
// ── Pull to refresh ──
|
||||
//
|
||||
// A pull here used to drag the page off the bottom of the screen and
|
||||
@@ -187,7 +201,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
// layered pages and one flat one. Same recipe here: canvas ground, one
|
||||
// panel per section, the labels on the canvas between them.
|
||||
body: MilerSheet(
|
||||
color: MilerSurface.canvas,
|
||||
color: Narrative.canvas,
|
||||
child: SettingsBody(
|
||||
onRefresh: _refresh,
|
||||
// The page's single left edge — the same gutter Activity uses, so a
|
||||
@@ -233,8 +247,8 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
// he has finished today, which is the glance he actually makes
|
||||
// between stops.
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: MilerSurface.panelGutter + MilerSurface.panelPad,
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: MilerSurface.panelGutter,
|
||||
),
|
||||
child: const SettingsLabel('Money'),
|
||||
),
|
||||
@@ -257,8 +271,8 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: MilerSurface.panelGutter + MilerSurface.panelPad,
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: MilerSurface.panelGutter,
|
||||
),
|
||||
child: const SettingsLabel('Account'),
|
||||
),
|
||||
@@ -305,8 +319,8 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: MilerSurface.panelGutter + MilerSurface.panelPad,
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: MilerSurface.panelGutter,
|
||||
),
|
||||
child: const SettingsLabel('Support'),
|
||||
),
|
||||
|
||||
@@ -52,20 +52,21 @@ class _FaqPageState extends State<FaqPage> {
|
||||
static const List<_Faq> _faqs = [
|
||||
_Faq(
|
||||
'What is a trip, and why can I only work one stop at a time?',
|
||||
'Your hub assigns a whole slot as one trip: pick up from every customer '
|
||||
'on it, then return to the hub. The stops are ordered for you, so the '
|
||||
'Your office assigns a whole slot as one trip: pick up from every '
|
||||
'customer on it, then return to base. The stops are ordered for you, '
|
||||
'so the '
|
||||
'app opens them one at a time — finish the active stop before the '
|
||||
'next one unlocks.',
|
||||
),
|
||||
_Faq(
|
||||
'A customer is not answering. Can I move them to the end?',
|
||||
'No — a trip keeps its order. Skip the stop with a reason instead. It '
|
||||
'comes out of your queue, your hub sees why, and the rest of the trip '
|
||||
'comes out of your queue, your office sees why, and the rest of the trip '
|
||||
'carries on unchanged.',
|
||||
),
|
||||
_Faq(
|
||||
'I am running late because of traffic. What should I do?',
|
||||
'Keep the app open so your location keeps reporting — the hub can see '
|
||||
'Keep the app open so your location keeps reporting — your office can see '
|
||||
'the delay without you calling. If a customer needs to know, use the '
|
||||
'call button on the stop.',
|
||||
),
|
||||
@@ -79,12 +80,12 @@ class _FaqPageState extends State<FaqPage> {
|
||||
_Faq(
|
||||
'I collected cash. Whose money is it?',
|
||||
'It is the customer\'s payment to the business you are picking up from, '
|
||||
'held by you until you hand it in at the hub. Record it in the app at '
|
||||
'held by you until you hand it in at the end of your shift. Record it in the app at '
|
||||
'the stop so the amount you carry and the amount expected match.',
|
||||
),
|
||||
_Faq(
|
||||
'When do I get paid, and what are points?',
|
||||
'Earnings are settled by your hub on its regular cycle — Earnings shows '
|
||||
'Earnings are settled by your office on its regular cycle — Earnings shows '
|
||||
'what has been counted so far. Points are separate: they are earned '
|
||||
'on completed trips and shown under Rewards.',
|
||||
),
|
||||
@@ -98,7 +99,7 @@ class _FaqPageState extends State<FaqPage> {
|
||||
'How do I change my name, photo or address?',
|
||||
'Account > Edit Profile for your name, photo and email; Account > Saved '
|
||||
'Address for your address. Your mobile number is fixed to the one '
|
||||
'your hub registered — contact support to change it.',
|
||||
'your office registered — contact support to change it.',
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class HelpCenter extends StatelessWidget {
|
||||
),
|
||||
SettingsRow(
|
||||
icon: LucideIcons.phone,
|
||||
title: 'Call the hub',
|
||||
title: 'Call the office',
|
||||
subtitle: 'For anything that cannot wait',
|
||||
onTap: () => _dial(context, _supportPhone),
|
||||
),
|
||||
|
||||
@@ -489,7 +489,7 @@ class _RiderRewardsPageState extends State<RiderRewardsPage> {
|
||||
_ruleDivider(),
|
||||
_rule(
|
||||
icon: LucideIcons.route,
|
||||
title: 'Ride the route your hub assigned',
|
||||
title: 'Ride the route your office assigned',
|
||||
body:
|
||||
'Wandering well past the planned distance breaks the '
|
||||
'route check for that stop.',
|
||||
@@ -534,8 +534,8 @@ class _RiderRewardsPageState extends State<RiderRewardsPage> {
|
||||
SizedBox(width: 7.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Your hub sets what each status is worth and can change the '
|
||||
'thresholds. Ask your hub manager what your level earns you.',
|
||||
'Your office sets what each status is worth and can change the '
|
||||
'thresholds. Ask your manager what your level earns you.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
height: 1.45,
|
||||
|
||||
@@ -132,7 +132,7 @@ class _SavedAddressPageState extends State<SavedAddressPage> {
|
||||
children: [
|
||||
_editing ? _editor() : _addressCard(),
|
||||
const SettingsHint(
|
||||
'Your hub uses this to work out which slots start near you, '
|
||||
'Your office uses this to work out which slots start near you, '
|
||||
'and it is where your shift is assumed to end.',
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:miler/Models/summary/riderweeklykms.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||||
import 'package:miler/controllers/summary_controller.dart';
|
||||
import 'package:miler/controllers/rewards_controller.dart';
|
||||
@@ -92,59 +93,89 @@ class _SummaryState extends State<Summary> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
// ── The same ground and the same curve as the four tabs ──
|
||||
//
|
||||
// It was `surface`, the warm off-white, on a page with no sheet at all —
|
||||
// so the one screen a rider opens to look at his money was the one screen
|
||||
// that did not belong to the app around it. The brand behind the sheet's
|
||||
// two top corners, the canvas under the cards, the gutter and the radii
|
||||
// are all the shared ones now.
|
||||
backgroundColor: ColorConstants.primary,
|
||||
// Earnings is no longer a tab — it opens from Account — so it needs a way
|
||||
// back that the four tabs never did.
|
||||
appBar: const MilerAppBar(title: 'Earnings', showBack: true),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refreshData,
|
||||
color: ColorConstants.primary,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 16.h),
|
||||
_buildPeriodTabs(),
|
||||
SizedBox(height: 16.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: _buildPickupsHero(),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: _buildStatsGrid(),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: TotalDistanceCard(
|
||||
userId: _userId,
|
||||
refreshTick: _refreshTick,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
],
|
||||
),
|
||||
// `pageColor`: this Scaffold is brand-red, so the curve names the sheet
|
||||
// under it instead — see [milerBrandSurface].
|
||||
appBar: const MilerAppBar(
|
||||
title: 'Earnings',
|
||||
showBack: true,
|
||||
pageColor: ColorConstants.pureSurface,
|
||||
),
|
||||
body: MilerSheet(
|
||||
// White, like Home, Activity and the record page. It was
|
||||
// [Narrative.canvas] — a grey ground for panels that carry a hairline
|
||||
// and a lift of their own, so the grey was a second separator under
|
||||
// things that already had one. Four screens, one colour.
|
||||
color: ColorConstants.pureSurface,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refreshData,
|
||||
color: ColorConstants.primary,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
],
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 14.h),
|
||||
_buildPeriodTabs(),
|
||||
SizedBox(height: Narrative.gapBlock.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: Narrative.gutter.w,
|
||||
),
|
||||
child: _buildPickupsHero(),
|
||||
),
|
||||
SizedBox(height: Narrative.gapBlock.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: Narrative.gutter.w,
|
||||
),
|
||||
child: _buildStatsGrid(),
|
||||
),
|
||||
SizedBox(height: Narrative.gapBlock.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: Narrative.gutter.w,
|
||||
),
|
||||
child: TotalDistanceCard(
|
||||
userId: _userId,
|
||||
refreshTick: _refreshTick,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPeriodTabs() {
|
||||
// The app's segmented control, the same object as Activity's filter and
|
||||
// Home's trip tabs: a tonal track, one white pill, the selected label in
|
||||
// the brand. It was a bordered white bar with a *filled maroon* segment —
|
||||
// a third idiom for the one interaction, and the only place in the app
|
||||
// where a selected tab was a solid brand slab.
|
||||
return Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
height: 44.h,
|
||||
margin: EdgeInsets.symmetric(horizontal: Narrative.gutter.w),
|
||||
height: 46.h,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.outlineVariant),
|
||||
color: Narrative.insetDeep,
|
||||
borderRadius: BorderRadius.circular(Narrative.radiusPill),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(_periods.length, (index) {
|
||||
@@ -158,23 +189,23 @@ class _SummaryState extends State<Summary> {
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
child: AnimatedContainer(
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
margin: EdgeInsets.all(4.r),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? ColorConstants.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
color: isSelected ? Narrative.card : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Narrative.radiusPill),
|
||||
boxShadow: isSelected ? Narrative.cardShadow : null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_periods[index],
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
letterSpacing: -0.1,
|
||||
color: isSelected ? Narrative.burgundy : Narrative.inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -203,11 +234,14 @@ class _SummaryState extends State<Summary> {
|
||||
// not an effect. The two faint circles behind it survive: they are what
|
||||
// stops a solid maroon slab reading as a error state.
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
color: Narrative.burgundy,
|
||||
borderRadius: BorderRadius.circular(Narrative.radiusCard),
|
||||
// The one celebratory surface in the app, and the only place the
|
||||
// brand's own glow is spent.
|
||||
boxShadow: Narrative.burgundyGlow,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderRadius: BorderRadius.circular(Narrative.radiusCard),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
@@ -317,14 +351,30 @@ class _SummaryState extends State<Summary> {
|
||||
icon: LucideIcons.circleMinus,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: _buildHeroMetric(
|
||||
'$points',
|
||||
'Reward pts',
|
||||
icon: LucideIcons.star,
|
||||
// ── The reward tile is withheld, not zeroed ──
|
||||
//
|
||||
// `bonuspoints` is never written — confirmed by the
|
||||
// backend on 24 Aug, along with the fact that
|
||||
// `ridercharges` holds the *client's* order price rather
|
||||
// than any rider payout, and that a real rate-card is a
|
||||
// separate build. A tile reading **0 Reward pts** on
|
||||
// every screen of every shift does not say "not built
|
||||
// yet"; it says "you have earned nothing", which is a
|
||||
// worse thing to tell a rider than saying nothing.
|
||||
//
|
||||
// It comes back the moment the figure is real: the tile
|
||||
// is drawn whenever the source is non-zero, so no code
|
||||
// has to change when the backend starts writing it.
|
||||
if (points > 0) ...[
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: _buildHeroMetric(
|
||||
'$points',
|
||||
'Reward pts',
|
||||
icon: LucideIcons.star,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -437,8 +487,15 @@ class _SummaryState extends State<Summary> {
|
||||
// competes with the hero above it for the reader's first look.
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.cardSurface,
|
||||
// A hairline and a soft lift on white, rather than a tonal fill.
|
||||
// The fill was carrying the separation while the page had a grey
|
||||
// ground to sit on; on white it was a fourth surface tone doing
|
||||
// what an edge does more quietly. Same recipe as every other card
|
||||
// in the app — see [Narrative.cardBox].
|
||||
color: Narrative.card,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: Narrative.cardBorder),
|
||||
boxShadow: Narrative.cardShadow,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
child: IntrinsicHeight(
|
||||
@@ -575,6 +632,15 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
|
||||
|
||||
String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3);
|
||||
|
||||
/// `Mon` … `Sun`, for matching a chart row against the calendar.
|
||||
///
|
||||
/// Written out rather than taken from `intl`: the series' own day names are
|
||||
/// whatever the backend sends, and the only thing being compared is the first
|
||||
/// three letters — so a locale-aware formatter would introduce a mismatch
|
||||
/// this comparison cannot have.
|
||||
String _weekdayName(DateTime d) =>
|
||||
const ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d.weekday - 1];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -713,16 +779,74 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
|
||||
|
||||
Widget _buildChart(List<RiderWeeklyKms> details, double totalKms) {
|
||||
final values = details.map((e) => e.kms).toList();
|
||||
final maxVal = values.reduce((a, b) => a > b ? a : b);
|
||||
final safeMax = maxVal <= 0 ? 1.0 : maxVal;
|
||||
final maxVal = values.isEmpty
|
||||
? 0.0
|
||||
: values.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
// Best day + averages for the summary row.
|
||||
int bestIdx = 0;
|
||||
for (int i = 1; i < values.length; i++) {
|
||||
if (values[i] > values[bestIdx]) bestIdx = i;
|
||||
// ── A week with nothing in it says so ──
|
||||
//
|
||||
// Seven bars of zero under a chart frame is not an empty state, it is a
|
||||
// chart that looks broken — and it looked exactly like the bug this page
|
||||
// had for months, where the series was never being read at all. An axis
|
||||
// with nothing on it should be replaced, not drawn.
|
||||
if (maxVal <= 0) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 34.h),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.chartNoAxesColumn,
|
||||
size: 28.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
'No distance recorded this week',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'Kilometres appear here once a stop is completed.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final safeMax = maxVal;
|
||||
|
||||
final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length;
|
||||
|
||||
// ── Today, from the same series the chart is drawn from ──
|
||||
//
|
||||
// Matched on the day name rather than taken from a second endpoint: the
|
||||
// bars and this figure have to agree, and the only way to guarantee that is
|
||||
// for them to be the same number. A week whose rows carry no day names —
|
||||
// which happens, and is why `Best day` used to render blank — simply has no
|
||||
// today to point at, and the row prints the em dash.
|
||||
final today = _shortDay(_weekdayName(DateTime.now())).toLowerCase();
|
||||
RiderWeeklyKms? todayRow;
|
||||
for (final d in details) {
|
||||
if (_shortDay(d.day).toLowerCase() == today) {
|
||||
todayRow = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
@@ -843,17 +967,28 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
|
||||
//
|
||||
// These were amber / brand-red / blue — three hues for three ledger
|
||||
// facts, on the one page family where colour is reserved for the hero
|
||||
// and the chart's own bars. And "Best Day" rendered blank when the
|
||||
// week's data carries no day names, leaving a caption with nothing
|
||||
// above it; an absent fact prints the em dash like everywhere else.
|
||||
// and the chart's own bars.
|
||||
//
|
||||
// ── `Best day` gave way to `Today` ──
|
||||
//
|
||||
// Best day named a weekday — `Wed` — which is a fact about the past
|
||||
// that a rider can do nothing with, and it rendered blank whenever the
|
||||
// week's rows carried no day names. What he opens this page to find out
|
||||
// is how far he has ridden *today*, and until now the app said that
|
||||
// nowhere: the distance was per-stop on Activity and per-week here,
|
||||
// with nothing in between.
|
||||
//
|
||||
// It also puts the same figure on two screens — this and the Activity
|
||||
// hero — which is deliberate: they are read at different moments, and
|
||||
// both take it from a measurement rather than from each other.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildChartStat(
|
||||
'Best day',
|
||||
_shortDay(details[bestIdx].day).trim().isEmpty
|
||||
'Today',
|
||||
todayRow == null
|
||||
? '\u2014'
|
||||
: _shortDay(details[bestIdx].day),
|
||||
: '${todayRow.kms.toStringAsFixed(1)} km',
|
||||
ColorConstants.slateText,
|
||||
),
|
||||
_buildChartStat(
|
||||
|
||||
Reference in New Issue
Block a user