Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.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';
|
||||
@@ -17,7 +19,7 @@ import 'package:miler/widget/Bottom_page.dart';
|
||||
/// three stops and scrolled back to the top had nothing on screen telling him
|
||||
/// so, and no way through.
|
||||
///
|
||||
/// This says both things in one control, in one fixed place: **how many
|
||||
/// This says both things in one control, in one fixed p~lace: **how many
|
||||
/// bookings he is holding**, and one tap to go work them. It is the counterpart
|
||||
/// to [SelectionBar] — that one is about the stops he is choosing, this one
|
||||
/// about the stops he has already taken — so they share the floating slot at
|
||||
@@ -29,6 +31,10 @@ import 'package:miler/widget/Bottom_page.dart';
|
||||
/// at some point today".
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class AcceptedPill extends StatelessWidget {
|
||||
/// The tallest this pill gets, excluding the device inset. See
|
||||
/// [SelectionBar.maxHeight] for why the surface declares its own.
|
||||
static double get maxHeight => ButtonSizes.compact + 24.h;
|
||||
|
||||
/// Accepted bookings still waiting to be worked. Zero renders nothing.
|
||||
final int count;
|
||||
|
||||
@@ -41,6 +47,8 @@ class AcceptedPill extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
if (count <= 0) return const SizedBox.shrink();
|
||||
|
||||
final profile = ServiceProfile.active;
|
||||
|
||||
final radius = BorderRadius.circular(DesignConstants.radiusFull);
|
||||
|
||||
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
|
||||
@@ -64,11 +72,29 @@ class AcceptedPill extends StatelessWidget {
|
||||
alignment: Alignment.centerRight,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
// The noun follows the rider's line of work — "booking" on a
|
||||
// parcel route, "delivery" on a meal run. See [ServiceProfile].
|
||||
label: count == 1
|
||||
? '1 accepted booking. Go to Bookings.'
|
||||
: '$count accepted bookings. Go to Bookings.',
|
||||
? '1 ${profile.jobNoun} ${_verb(profile)}. '
|
||||
'Go to ${profile.workTabLabel}.'
|
||||
: '$count ${profile.jobNounPlural} ${_verb(profile)}. '
|
||||
'Go to ${profile.workTabLabel}.',
|
||||
// ── Red is for the action, not for the notice ──
|
||||
//
|
||||
// This was a solid brand-red slab with a white disc in it, floating
|
||||
// over the foot of the route. On a page whose one accent moment is
|
||||
// supposed to be the journey button, it was the largest and
|
||||
// reddest object on the screen — and it is not an action the screen
|
||||
// exists for at all. It is a *notice with a way through*: "you are
|
||||
// holding nine of these, they are over there."
|
||||
//
|
||||
// So the surface goes quiet — the same lifted white the selection
|
||||
// bar uses, which is the app's one "floating over the list"
|
||||
// material — and the brand is spent on the two things that carry
|
||||
// the meaning: the count and the arrow out. Same height, same
|
||||
// wording, a third of the ink.
|
||||
child: Material(
|
||||
color: ColorConstants.primary,
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: radius,
|
||||
elevation: 0,
|
||||
child: InkWell(
|
||||
@@ -78,10 +104,11 @@ class AcceptedPill extends StatelessWidget {
|
||||
// 48 clears the Material touch-target floor without the pill
|
||||
// reading as a primary CTA.
|
||||
height: 48.h,
|
||||
padding: EdgeInsets.fromLTRB(8.w, 0, 14.w, 0),
|
||||
padding: EdgeInsets.fromLTRB(7.w, 0, 10.w, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: radius,
|
||||
boxShadow: DesignConstants.shadowLg,
|
||||
boxShadow: DesignConstants.shadowFloat,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -90,41 +117,52 @@ class AcceptedPill extends StatelessWidget {
|
||||
// is the thing the rider is checking from across the
|
||||
// handlebars, and it changes while the words do not.
|
||||
Container(
|
||||
width: 32.w,
|
||||
height: 32.w,
|
||||
width: 30.w,
|
||||
height: 30.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
color: ColorConstants.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.primary,
|
||||
color: ColorConstants.onAccent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 9.w),
|
||||
Text(
|
||||
count == 1 ? 'booking accepted' : 'bookings accepted',
|
||||
// ── The word has to match the boundary ──
|
||||
//
|
||||
// It said "accepted" on both lines. On a round the
|
||||
// count is `WorkBoundary.deliveryQueue` — work already
|
||||
// **collected** and waiting to be delivered — so a
|
||||
// rider holding twenty-one bags read "21 deliveries
|
||||
// accepted", which is the wrong verb for the wrong
|
||||
// rung and understates what he is carrying.
|
||||
//
|
||||
// Logistics hands over at acceptance, so there the word
|
||||
// is right. Same knob the boundary itself reads.
|
||||
_waiting(profile, count),
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: Colors.white,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 2.w),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
LucideIcons.chevronRight,
|
||||
size: 20.sp,
|
||||
color: Colors.white,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -136,4 +174,19 @@ class AcceptedPill extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The verb for the rung this count actually sits on.
|
||||
///
|
||||
/// The pill counts `WorkBoundary.deliveryQueue`, and where that boundary sits
|
||||
/// is a property of the line — [ServiceProfile.handoffAt]. On a round it is
|
||||
/// **pickup-complete**, so the work is in the rider's hands; on logistics it
|
||||
/// is acceptance, so "accepted" is exactly right. One knob, read here rather
|
||||
/// than a line-name check.
|
||||
static String _verb(ServiceProfile profile) =>
|
||||
profile.handsOffAtCollection ? 'in hand' : 'accepted';
|
||||
|
||||
/// `deliveries in hand` · `bookings accepted`.
|
||||
static String _waiting(ServiceProfile profile, int count) =>
|
||||
'${count == 1 ? profile.jobNoun : profile.jobNounPlural} '
|
||||
'${_verb(profile)}';
|
||||
}
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart' show milerGlassSheet;
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// GOING ON / OFF DUTY
|
||||
///
|
||||
/// The confirmation behind the duty switch in the app bar. It used to be a
|
||||
/// white slab with a 32pt red ✕ floating above a 88pt red circle holding a
|
||||
/// **wifi** glyph, under the heading "Go Online?" and one grey line — "You
|
||||
/// will stop receiving bookings".
|
||||
/// One decision, asked once.
|
||||
///
|
||||
/// Three things were wrong with that, and none of them was the styling:
|
||||
/// ── What this had accumulated ──
|
||||
///
|
||||
/// • **Wifi is the wrong idea.** Duty is not connectivity. A rider reading a
|
||||
/// wifi symbol on a screen that stops his work is being told the app has a
|
||||
/// network problem.
|
||||
/// • **It did not say what changes.** "You will stop receiving bookings" is
|
||||
/// one consequence of three. Live tracking stops, and — the one that
|
||||
/// actually catches people out — the app will refuse to take him off duty
|
||||
/// while a booking is still open. He found that out *after* sliding.
|
||||
/// • **It was the only sheet in the app that looked like this.** Every other
|
||||
/// sheet is frosted glass with a grab handle and a small round close
|
||||
/// button; see [milerGlassSheet].
|
||||
/// A grab handle, a tinted icon disc, a title, a subtitle, a **drawn diagram of
|
||||
/// the switch moving from one state to the other**, three icon-and-text rows
|
||||
/// naming the consequences, and a slide-to-confirm. Eight blocks for a question
|
||||
/// with two answers — the shape of a screen assembled from good intentions one
|
||||
/// at a time rather than designed.
|
||||
///
|
||||
/// So: the app's own sheet chrome, the duty glyph the rest of the app uses,
|
||||
/// a strip showing the switch moving from one state to the other, and the
|
||||
/// consequences named one per line instead of summarised in a sentence.
|
||||
/// Each piece was individually defensible and the total was not. The diagram
|
||||
/// illustrated a state change the title had already stated. The consequence
|
||||
/// rows were three lines of icons for facts that fit in one sentence. And the
|
||||
/// slide was borrowed from the payment flows, where the app's own rule is that
|
||||
/// **slides are reserved for money** — a rider ending his shift is not
|
||||
/// confirming a transaction, and asking for a drag rather than a tap made the
|
||||
/// commonest action of his day the most laborious.
|
||||
///
|
||||
/// The slide-to-confirm survives both directions. Going off duty mid-shift is
|
||||
/// worth a deliberate gesture, and a rider whose thumb is wet from the rain
|
||||
/// should not be able to end his day with a stray tap.
|
||||
/// ── What it is ──
|
||||
///
|
||||
/// The question, one sentence of consequence, and two answers with an obvious
|
||||
/// primary. Nothing is illustrated that is also written. Going off duty is the
|
||||
/// consequential direction, so it carries the brand and states the one thing
|
||||
/// that genuinely catches riders out — the app will not let him off duty while
|
||||
/// a booking is open — in the sentence rather than after a failed gesture.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class DutySheet extends StatelessWidget {
|
||||
/// True when this sheet is asking to go **on** duty.
|
||||
@@ -64,15 +62,9 @@ class DutySheet extends StatelessWidget {
|
||||
required bool goingOnline,
|
||||
required Future<bool> Function() onConfirm,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
sheetAnimationStyle: kMilerSheetStyle,
|
||||
isScrollControlled: true,
|
||||
// The sheet paints its own frosted shape — a white sheet behind it would
|
||||
// show as a square corner behind the rounded one.
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) =>
|
||||
DutySheet(goingOnline: goingOnline, onConfirm: onConfirm),
|
||||
return showMilerSheet<void>(
|
||||
context,
|
||||
builder: (_) => DutySheet(goingOnline: goingOnline, onConfirm: onConfirm),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,395 +79,116 @@ class DutySheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: true,
|
||||
child: milerGlassSheet(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40.w,
|
||||
height: 4.h,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.borderSubtle,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_header(context),
|
||||
SizedBox(height: 18.h),
|
||||
// What the switch is about to do, drawn rather than described.
|
||||
_TransitionStrip(goingOnline: goingOnline, accent: _accent),
|
||||
SizedBox(height: 16.h),
|
||||
..._consequences(),
|
||||
SizedBox(height: 20.h),
|
||||
_slider(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// The duty glyph, not a wifi bar. Same icon the "Go on duty" button on
|
||||
// an empty trip carries, so one idea keeps one symbol.
|
||||
Container(
|
||||
padding: EdgeInsets.all(10.r),
|
||||
decoration: BoxDecoration(
|
||||
color: _accent.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.power_settings_new_rounded,
|
||||
color: _accent,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
goingOnline ? 'Go on duty?' : 'Go off duty?',
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
goingOnline
|
||||
? 'Your hub can start assigning you trips again.'
|
||||
: 'Your hub stops assigning you trips until you are back.',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
// The same small round dismiss every other sheet uses, in place of the
|
||||
// 32pt red ✕ that used to sit alone on its own row above everything.
|
||||
InkWell(
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(8.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
color: ColorConstants.slateText,
|
||||
size: 18.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// One line per thing that actually changes.
|
||||
///
|
||||
/// The third off-duty line is the one the old sheet withheld: the app blocks
|
||||
/// the switch while a booking is still open, and it used to say so only
|
||||
/// after the rider had completed the slide and watched it bounce back.
|
||||
List<Widget> _consequences() {
|
||||
final rows = goingOnline
|
||||
? const [
|
||||
(Icons.alt_route_rounded, 'Your hub can add you to the next slot'),
|
||||
(
|
||||
Icons.my_location_rounded,
|
||||
'Live tracking runs while you are on duty',
|
||||
),
|
||||
]
|
||||
: const [
|
||||
(
|
||||
Icons.notifications_paused_rounded,
|
||||
'No new trips until you are back on',
|
||||
),
|
||||
(Icons.location_off_rounded, 'Live tracking pauses'),
|
||||
(
|
||||
Icons.inventory_2_rounded,
|
||||
'Any booking you are holding must be finished first',
|
||||
),
|
||||
];
|
||||
|
||||
return [
|
||||
for (var i = 0; i < rows.length; i++) ...[
|
||||
if (i > 0) SizedBox(height: 10.h),
|
||||
_ConsequenceRow(
|
||||
icon: rows[i].$1,
|
||||
label: rows[i].$2,
|
||||
accent: _accent,
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _slider(BuildContext context) {
|
||||
final label = goingOnline ? 'Slide to go on duty' : 'Slide to go off duty';
|
||||
|
||||
return SlideToSubmit.custom(
|
||||
height: 58,
|
||||
sliderWidth: 46,
|
||||
padding: const EdgeInsets.all(6),
|
||||
// The track is the accent at low alpha, so the control is the same colour
|
||||
// as the decision. It was a flat grey slab that gave no clue which of the
|
||||
// two things it was about to do.
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: _accent.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
foregroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||
slider: Center(
|
||||
child: Container(
|
||||
height: 46,
|
||||
width: 46,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
// A filled knob in the accent, not a white disc with a grey
|
||||
// chevron: on a tinted track the white one read as the empty part.
|
||||
color: _accent,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _accent.withValues(alpha: 0.30),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.onAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Words, not just a drifting arrow. The old hint was an arrow asset and
|
||||
// nothing else, which says "something slides" without saying what
|
||||
// happens when it lands.
|
||||
hint: IgnorePointer(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(width: 46.0 + 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: _accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 14.w),
|
||||
// Boxed and clipped. The arrow animates in from `Offset(-2, 0)` —
|
||||
// two of its own widths to the left — so left to itself it drifts
|
||||
// straight across the label and prints an arrow through the
|
||||
// middle of the words. Its own box turns that into what it was
|
||||
// meant to be: an arrow sliding in at the end of the track.
|
||||
child: SizedBox(
|
||||
width: 26.w,
|
||||
child: ClipRect(
|
||||
// The asset is a fixed dark glyph, so it is tinted rather
|
||||
// than dropped: the track is coloured now, and a black arrow
|
||||
// on a green track was the one thing on the control that did
|
||||
// not belong to it.
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
_accent.withValues(alpha: 0.55),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: const AnimatedSlideArrow(
|
||||
arrowImage: AssetImage(
|
||||
'assets/arrow_right.png',
|
||||
package: 'slide_to_submit_button',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// The knob springs back whatever happens, and the sheet closes only when
|
||||
// duty actually changed — a failed API call leaves the rider looking at
|
||||
// the decision he was trying to make, not at a screen that has silently
|
||||
// dropped it.
|
||||
onSubmit: (controller) async {
|
||||
final ok = await onConfirm();
|
||||
try {
|
||||
controller.reset();
|
||||
} catch (_) {}
|
||||
if (ok && context.mounted) Navigator.of(context).maybePop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Off duty ──→ On duty`, as the two states of the switch that opened this.
|
||||
///
|
||||
/// A confirmation should show the change, not only name it. The state he is
|
||||
/// leaving stays grey and the one he is heading for takes the accent, so which
|
||||
/// direction this sheet runs in is legible before a word is read.
|
||||
class _TransitionStrip extends StatelessWidget {
|
||||
final bool goingOnline;
|
||||
final Color accent;
|
||||
|
||||
const _TransitionStrip({required this.goingOnline, required this.accent});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StateChip(
|
||||
label: goingOnline ? 'Off duty' : 'On duty',
|
||||
color: ColorConstants.secondaryText,
|
||||
filled: false,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.borderStrong,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _StateChip(
|
||||
label: goingOnline ? 'On duty' : 'Off duty',
|
||||
color: accent,
|
||||
filled: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StateChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
/// The destination state. The one being left is drawn hollow.
|
||||
final bool filled;
|
||||
|
||||
const _StateChip({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.filled,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: filled ? color.withValues(alpha: 0.12) : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: filled ? Colors.transparent : ColorConstants.borderStrong,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
// ── The SafeArea used to wrap the GLASS ──
|
||||
//
|
||||
// So the frosted surface stopped at the top of the gesture inset and the
|
||||
// brand-red home indicator area showed through beneath it — a coloured
|
||||
// stripe under every duty change. The kit pads *inside* the fabric.
|
||||
return MilerSheetScaffold(
|
||||
padding: EdgeInsets.fromLTRB(22.w, 0, 22.w, 18.h),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 8.w,
|
||||
height: 8.w,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: filled ? color : ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
class _ConsequenceRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color accent;
|
||||
|
||||
const _ConsequenceRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 1.h),
|
||||
child: Icon(icon, size: 17.sp, color: accent.withValues(alpha: 0.75)),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
Text(
|
||||
goingOnline ? 'Go on duty?' : 'Go off duty?',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 21.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
// One sentence. The off-duty version carries the fact that used
|
||||
// to be discovered only after a failed slide.
|
||||
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.',
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
_confirm(context),
|
||||
SizedBox(height: 4.h),
|
||||
_dismiss(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The answer, as a button. Green to start the day, brand to end it — the
|
||||
/// same two colours the duty switch itself uses, so the sheet is visibly
|
||||
/// about the control that opened it.
|
||||
Widget _confirm(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: ButtonSizes.primary,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final changed = await onConfirm();
|
||||
if (changed && context.mounted) Navigator.of(context).maybePop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: ColorConstants.onAccent,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
goingOnline ? 'Go on duty' : 'Go off duty',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.onAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The way out, stated as what it leaves him as rather than as "Cancel" — a
|
||||
/// rider reading two buttons should not have to work out which one is the
|
||||
/// no-op.
|
||||
Widget _dismiss(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: ButtonSizes.secondary,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: ColorConstants.secondaryText,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
goingOnline ? 'Not yet' : 'Stay on duty',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/widget/Bottom_page.dart';
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
@@ -29,6 +31,15 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
/// So the banner lifts itself clear by [BottomPage.bottomInset]. Both call sites
|
||||
/// just pin it to `bottom: 0` and let it place itself.
|
||||
class ActivePickupBanner extends StatefulWidget {
|
||||
/// The tallest this banner gets, excluding the device inset.
|
||||
///
|
||||
/// Declared here because the surface that overlays a list is the only thing
|
||||
/// that knows how much of the list it covers. The Deliveries queue reserved
|
||||
/// a flat `80.h` for it — a number measured once, by eye, that every later
|
||||
/// change to this widget made wrong silently by hiding the last row of the
|
||||
/// run behind it.
|
||||
static double get maxHeight => ButtonSizes.primary + 46.h;
|
||||
|
||||
final List<Map<String, dynamic>> activePickups;
|
||||
final void Function(Map<String, dynamic>) onTap;
|
||||
|
||||
@@ -166,11 +177,7 @@ class _Card extends StatelessWidget {
|
||||
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.two_wheeler_rounded,
|
||||
color: Colors.white,
|
||||
size: 22.sp,
|
||||
),
|
||||
child: Icon(LucideIcons.bike, color: Colors.white, size: 22.sp),
|
||||
),
|
||||
SizedBox(width: 11.w),
|
||||
Expanded(
|
||||
@@ -196,7 +203,7 @@ class _Card extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -208,7 +215,7 @@ class _Card extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.place_rounded,
|
||||
LucideIcons.mapPin,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
size: 13.sp,
|
||||
),
|
||||
@@ -244,7 +251,7 @@ class _Card extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
LucideIcons.arrowRight,
|
||||
color: Colors.white,
|
||||
size: 16.sp,
|
||||
),
|
||||
@@ -312,11 +319,11 @@ class _LiveChip extends StatelessWidget {
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Text(
|
||||
'LIVE',
|
||||
'Active',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.7,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
|
||||
380
lib/views/Dashboard/home/pickup_preview_sheet.dart
Normal file
380
lib/views/Dashboard/home/pickup_preview_sheet.dart
Normal file
@@ -0,0 +1,380 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
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/widgets/miler_sheet_kit.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// PICKUP PREVIEW — where am I going, and what am I collecting there
|
||||
///
|
||||
/// ── Why this exists ──
|
||||
///
|
||||
/// `Navigate to pickup` used to hand straight off to Google Maps. That is one
|
||||
/// tap too few. Handing off means the rider **leaves Miler** — his route, his
|
||||
/// bag counts and his manifest all go behind another app — and he has to make
|
||||
/// that commitment on the strength of a kitchen's name and a straight-line
|
||||
/// distance. If the hub has routed him somewhere he does not recognise, he finds
|
||||
/// out inside a different app, already navigating.
|
||||
///
|
||||
/// So the button opens a preview first: the leg he is about to ride, drawn from
|
||||
/// where he is standing to where he is going, with the load he will pick up when
|
||||
/// he arrives. He confirms, and *then* Miler hands off.
|
||||
///
|
||||
/// It is the same surface the card's own tap opens, because they answer the same
|
||||
/// question — "what is this place?" — and a screen that answered it two
|
||||
/// different ways depending on which part of a card you touched would be worse
|
||||
/// than one that answers it once.
|
||||
///
|
||||
/// ── What is on it ──
|
||||
///
|
||||
/// ```
|
||||
/// ┌───────────────────────────────────────┐
|
||||
/// │ ● ─────────── ▼ │ ← the leg: him, the kitchen
|
||||
/// └───────────────────────────────────────┘
|
||||
/// Vidhya Kitchen
|
||||
/// ➤ 3.1 km ◷ ~9 min
|
||||
///
|
||||
/// 5 orders · 5 bags
|
||||
/// ○ Joe Mathew Bag 1
|
||||
/// ○ Arun Prakash Bag 2
|
||||
/// …
|
||||
///
|
||||
/// [ ➤ Start navigation ] [ 📞 ]
|
||||
/// ```
|
||||
///
|
||||
/// The manifest is read straight off [BagManifest] — the same source as the
|
||||
/// card, the confirmation sheet and the row, so the number he checks against the
|
||||
/// shelf cannot differ between the screen he decided on and the screen he
|
||||
/// confirms on. **One order is one bag**, here as everywhere.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class PickupPreviewSheet extends StatelessWidget {
|
||||
/// The place, as the card knows it.
|
||||
final String placeName;
|
||||
|
||||
/// Every order the rider collects here, in route order.
|
||||
final List<BagLine> lines;
|
||||
|
||||
/// Straight-line metres from the rider to the place. Null when unknown —
|
||||
/// nothing is invented, exactly as on the card.
|
||||
final double? meters;
|
||||
|
||||
/// Ride time to the place. Zero renders nothing.
|
||||
final Duration ride;
|
||||
|
||||
/// Where the rider is. Null draws the destination alone rather than a leg
|
||||
/// starting from a guess.
|
||||
final double? riderLat;
|
||||
final double? riderLng;
|
||||
|
||||
/// Hands off to the phone's navigation app. Null hides the action — a stop
|
||||
/// with no coordinates has nothing to navigate to.
|
||||
final VoidCallback? onNavigate;
|
||||
|
||||
/// Rings the place. Null hides the action.
|
||||
final VoidCallback? onCall;
|
||||
|
||||
const PickupPreviewSheet({
|
||||
super.key,
|
||||
required this.placeName,
|
||||
required this.lines,
|
||||
required this.meters,
|
||||
required this.ride,
|
||||
this.riderLat,
|
||||
this.riderLng,
|
||||
this.onNavigate,
|
||||
this.onCall,
|
||||
});
|
||||
|
||||
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String placeName,
|
||||
required List<BagLine> lines,
|
||||
required double? meters,
|
||||
required Duration ride,
|
||||
double? riderLat,
|
||||
double? riderLng,
|
||||
VoidCallback? onNavigate,
|
||||
VoidCallback? onCall,
|
||||
}) {
|
||||
return showMilerSheet<void>(
|
||||
context,
|
||||
large: true,
|
||||
builder: (_) => PickupPreviewSheet(
|
||||
placeName: placeName,
|
||||
lines: lines,
|
||||
meters: meters,
|
||||
ride: ride,
|
||||
riderLat: riderLat,
|
||||
riderLng: riderLng,
|
||||
onNavigate: onNavigate,
|
||||
onCall: onCall,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The place's own coordinates, taken from the first order collected there —
|
||||
/// they all share one counter, so they all share one pin.
|
||||
({double lat, double lng})? get _place {
|
||||
if (lines.isEmpty) return null;
|
||||
final stop = lines.first.stop;
|
||||
final lat = _d(stop['pickuplat'] ?? stop['PickupLat']);
|
||||
final lng = _d(stop['pickuplon'] ?? stop['PickupLon']);
|
||||
return (lat == 0 || lng == 0) ? null : (lat: lat, lng: lng);
|
||||
}
|
||||
|
||||
static double _d(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toDouble();
|
||||
return double.tryParse(v.toString().trim()) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final place = _place;
|
||||
|
||||
// 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(
|
||||
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),
|
||||
_manifest(),
|
||||
SizedBox(height: 18.h),
|
||||
_actions(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The leg, drawn once. Him, the counter, and the line between them.
|
||||
///
|
||||
/// Deliberately not interactive and deliberately not a navigation surface:
|
||||
/// this is an *orientation* aid answering "which way is that, and is it far".
|
||||
/// Turn-by-turn is the phone's job and the button below hands off to it.
|
||||
Widget _map(({double lat, double lng}) place) {
|
||||
final placePos = LatLng(place.lat, place.lng);
|
||||
final hasRider =
|
||||
riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0;
|
||||
final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
child: SizedBox(
|
||||
height: 190.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
|
||||
// [AfterEntrance].
|
||||
child: AfterEntrance(
|
||||
placeholder: MapPlaceholder(radius: 16.r),
|
||||
// The real road, both ends pinned, camera fitted to the pair — the
|
||||
// straight solid stroke this drew was geometry no road follows, and
|
||||
// a fixed zoom hid the half of the journey the rider is standing
|
||||
// in. See [MilerLegMap].
|
||||
builder: (context) => MilerLegMap(
|
||||
to: placePos,
|
||||
from: riderPos,
|
||||
accent: ColorConstants.primary,
|
||||
icon: StopKind.pickup.icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `➤ 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();
|
||||
|
||||
// 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(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// What he is collecting, and which bag each order travels in.
|
||||
///
|
||||
/// Capped and scrollable rather than unbounded: a counter can hand over
|
||||
/// fifteen orders, and a sheet that grows with the manifest pushes its own
|
||||
/// actions off the bottom of the screen — the one thing this surface exists to
|
||||
/// offer.
|
||||
Widget _manifest() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
BagManifest.countLabel(lines.length),
|
||||
style: MilerType.body.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
),
|
||||
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(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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// One primary action and one secondary, in the thumb zone.
|
||||
///
|
||||
/// "Start navigation" rather than "Navigate to pickup": by this point the
|
||||
/// rider is looking at the leg, so the label's job is no longer to say where —
|
||||
/// it is to say that pressing it *leaves Miler*.
|
||||
Widget _actions() {
|
||||
return Row(
|
||||
children: [
|
||||
if (onNavigate != null)
|
||||
Expanded(
|
||||
child: MilerButton(
|
||||
label: 'Start navigation',
|
||||
icon: LucideIcons.navigation,
|
||||
height: ButtonSizes.primary,
|
||||
onPressed: onNavigate,
|
||||
),
|
||||
),
|
||||
if (onNavigate != null && onCall != null) SizedBox(width: 10.w),
|
||||
if (onCall != null)
|
||||
MilerIconButton(
|
||||
icon: LucideIcons.phone,
|
||||
color: ColorConstants.acceptGreen,
|
||||
size: ButtonSizes.primary,
|
||||
semanticLabel: 'Call this pickup location',
|
||||
tonal: true,
|
||||
onPressed: onCall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
@@ -355,18 +356,14 @@ class ShiftBanner extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time_rounded,
|
||||
color: ColorConstants.primary,
|
||||
size: 15.sp,
|
||||
),
|
||||
Icon(LucideIcons.clock, color: ColorConstants.primary, size: 15.sp),
|
||||
SizedBox(width: 7.w),
|
||||
Text(
|
||||
brief.shiftWindowLabel,
|
||||
style: TextStyle(
|
||||
color: ColorConstants.slateText,
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -381,7 +378,7 @@ class ShiftBanner extends StatelessWidget {
|
||||
? ColorConstants.warning
|
||||
: ColorConstants.secondaryText),
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
|
||||
3163
lib/views/Dashboard/home/route_timeline.dart
Normal file
3163
lib/views/Dashboard/home/route_timeline.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
@@ -39,7 +40,79 @@ import 'package:miler/widget/Bottom_page.dart';
|
||||
/// untick each stop by hand, which on a six-stop route is six taps to undo one
|
||||
/// mistake.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// What the ticked stops are ready for.
|
||||
///
|
||||
/// A service rider works a kitchen as a batch: he ticks the five parcels that
|
||||
/// come from it, accepts them, walks in, slides *arrived*, is handed the crate,
|
||||
/// slides *picked up*. One gesture per rung for the whole set rather than five
|
||||
/// gestures per rung — the five bags arrived as one handover and the control
|
||||
/// should match what happened.
|
||||
enum SelectionAction {
|
||||
/// Take these stops on.
|
||||
accept,
|
||||
|
||||
/// Say he has reached the source.
|
||||
arrive,
|
||||
|
||||
/// Say the parcels are in his box. The last rung on Home.
|
||||
pickUp,
|
||||
}
|
||||
|
||||
extension SelectionActionX on SelectionAction {
|
||||
/// The words on the control, in the rider's voice.
|
||||
///
|
||||
/// "Mark as…" rather than "I've…": these two are *claims recorded against a
|
||||
/// place*, and the rider is not talking to the app, he is marking a set of
|
||||
/// orders. The wording also matches what the sheet behind it then asks him
|
||||
/// to confirm, so the button and the sheet read as one action rather than
|
||||
/// two.
|
||||
String get label => switch (this) {
|
||||
SelectionAction.accept => 'Accept',
|
||||
SelectionAction.arrive => 'Mark as Arrived',
|
||||
SelectionAction.pickUp => 'Mark as Picked',
|
||||
};
|
||||
|
||||
/// The status token this rung writes.
|
||||
String get status => switch (this) {
|
||||
SelectionAction.accept => 'ACCEPTED',
|
||||
SelectionAction.arrive => 'ARRIVED',
|
||||
SelectionAction.pickUp => 'PICKED',
|
||||
};
|
||||
|
||||
/// Accepting commits from the bar; the two that move real parcels go through
|
||||
/// a sheet with a slide in it.
|
||||
///
|
||||
/// Not decoration. "Picked" is the rider asserting he is holding somebody's
|
||||
/// lunch, and it is the last thing standing between the order and a delivery
|
||||
/// list — worth a deliberate gesture, and worth taking his eyes off a list of
|
||||
/// cards for the second it takes. Accepting is reversible and happens in bulk
|
||||
/// at the start of a shift, so it stays a tap.
|
||||
///
|
||||
/// The slide used to be *on* this bar, inline. It lost: a 44dp-high track
|
||||
/// squeezed between a ✕ and the screen edge is a gesture the rider misses
|
||||
/// with a thumb on a moving bike, and there was no room left to say what he
|
||||
/// was confirming. The bar now says what will happen and [StopActionSheet]
|
||||
/// gives the gesture a whole surface — see the note there.
|
||||
bool get confirmsInSheet => this != SelectionAction.accept;
|
||||
}
|
||||
|
||||
class SelectionBar extends StatelessWidget {
|
||||
/// The tallest this bar gets, excluding the device inset.
|
||||
///
|
||||
/// ── Why this is declared rather than guessed ──
|
||||
///
|
||||
/// Home reserved `80.h` of scroll extent to clear whatever floats over it.
|
||||
/// That number was measured once, by eye, against one of the two surfaces
|
||||
/// that can occupy the slot — and every later change to either (a second
|
||||
/// line of copy, a taller button, a text scale the tester did not try) made
|
||||
/// it wrong silently, by hiding the end of the route under the bar.
|
||||
///
|
||||
/// A control that overlays a list is the only thing that knows how much of
|
||||
/// the list it covers, so it says so. `ButtonSizes.primary` is the action
|
||||
/// row; the rest is this widget's own vertical padding, and the two-line
|
||||
/// accessibility layout is covered because the button grows with it.
|
||||
static double get maxHeight => ButtonSizes.primary + 28.h;
|
||||
|
||||
/// How many stops are ticked. Zero renders nothing.
|
||||
final int count;
|
||||
|
||||
@@ -56,6 +129,14 @@ class SelectionBar extends StatelessWidget {
|
||||
/// True while the accept is in flight.
|
||||
final bool busy;
|
||||
|
||||
/// Which rung the ticked stops are on. Anything past [SelectionAction.accept]
|
||||
/// swaps the buttons for a slide and drops Reject — a stop already taken on
|
||||
/// cannot be declined from here.
|
||||
final SelectionAction action;
|
||||
|
||||
/// Runs the rung named by [action]. Falls back to [onAccept] when null.
|
||||
final Future<void> Function()? onAdvance;
|
||||
|
||||
const SelectionBar({
|
||||
super.key,
|
||||
required this.count,
|
||||
@@ -63,6 +144,8 @@ class SelectionBar extends StatelessWidget {
|
||||
required this.onClear,
|
||||
this.onReject,
|
||||
this.busy = false,
|
||||
this.action = SelectionAction.accept,
|
||||
this.onAdvance,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -88,86 +171,140 @@ class SelectionBar extends StatelessWidget {
|
||||
// read that way against a list of white cards.
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(6.w, 8.h, 10.w, 8.h),
|
||||
// 8 → 6. The bar is a *command surface*, not a card: what should
|
||||
// read as generous is the buttons on it, not the air around them.
|
||||
padding: EdgeInsets.fromLTRB(6.w, 6.h, 8.w, 6.h),
|
||||
// Borderless, like every other surface on this screen: the lift is
|
||||
// what says "this is over the list", and a hairline under a shadow
|
||||
// only muddies the edge.
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.borderSubtle),
|
||||
boxShadow: DesignConstants.shadowLg,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: DesignConstants.shadowFloat,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_ClearButton(onTap: busy ? null : onClear),
|
||||
SizedBox(width: 2.w),
|
||||
// The running count, in words — but only when there is room for
|
||||
// it. With two decisions on the bar there is not: at 320pt the
|
||||
// three fixed controls plus this line overflowed by 94pt, and
|
||||
// "Accept 3" already carries the number. So the sentence is a
|
||||
// luxury for the one-button case and the space goes to the
|
||||
// buttons otherwise.
|
||||
if (onReject == null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$count ${count == 1 ? 'stop' : 'stops'} selected',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
child: action.confirmsInSheet
|
||||
// ── The rung, as one big button ──
|
||||
//
|
||||
// At this point in the shift the rider is standing at a counter
|
||||
// holding a crate, and there is exactly one thing to press.
|
||||
// Nothing shares the bar with it: no Reject (these orders are
|
||||
// already his), no running-count sentence (the count is on the
|
||||
// button), no slide track squeezed to whatever width was left.
|
||||
//
|
||||
// It is a full-width control because it is pressed with a thumb,
|
||||
// one-handed, without looking — and because the gesture that
|
||||
// actually commits is one layer away, in the sheet it opens, so
|
||||
// this press is safe to make easy.
|
||||
//
|
||||
// 56 → 48. At 56 inside an 8pt-padded bar the whole thing stood
|
||||
// 72pt off the nav bar and covered the last two rows of the
|
||||
// route it was acting on — a command surface must not hide its
|
||||
// own subject. 48 is the same button one size down the app's
|
||||
// own scale, still 20pt above the tap-target floor, and it
|
||||
// matches the height Reject and Accept use on the other branch
|
||||
// so the bar does not change stature between rungs.
|
||||
? Row(
|
||||
children: [
|
||||
_ClearButton(onTap: busy ? null : onClear),
|
||||
SizedBox(width: 6.w),
|
||||
Expanded(
|
||||
child: MilerButton(
|
||||
// `Mark as Arrived (3)`. The count in brackets, not
|
||||
// after a middot: a dotted suffix reads as a second
|
||||
// fact about the action, and this is the action's
|
||||
// own object — how many stops the press closes.
|
||||
label: '${action.label} ($count)',
|
||||
icon: action == SelectionAction.arrive
|
||||
? LucideIcons.mapPinCheck
|
||||
: LucideIcons.shoppingBag,
|
||||
color: action == SelectionAction.arrive
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.acceptGreen,
|
||||
height: ButtonSizes.secondary,
|
||||
loading: busy,
|
||||
onPressed: busy ? null : () => onAdvance?.call(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
SizedBox(width: 6.w),
|
||||
// ── Both decisions, on the bar ──
|
||||
//
|
||||
// Reject used to live on every stop row and went when the rows
|
||||
// became tick-only. That left the rider able to say yes to a
|
||||
// selection and nothing else: declining a stop he could not do
|
||||
// — shop shut, address wrong — had no route at all, and the hub
|
||||
// learned about it by the stop simply never being accepted.
|
||||
//
|
||||
// So it comes back where Accept is, on the set he has picked.
|
||||
// Outlined and unlabelled-by-count against Accept's filled
|
||||
// green: they are not equal choices, and the one that ends work
|
||||
// should not look like the one that starts it.
|
||||
if (onReject != null) ...[
|
||||
Flexible(
|
||||
child: MilerButton(
|
||||
// No icon, and flexible: the width an icon costs is width
|
||||
// the two labels need side by side on a 320pt phone, and
|
||||
// under real pressure the pair should give ground evenly
|
||||
// rather than run off the edge of the bar.
|
||||
label: 'Reject',
|
||||
variant: MilerButtonVariant.outlined,
|
||||
color: ColorConstants.errorRed,
|
||||
height: ButtonSizes.secondary,
|
||||
expand: false,
|
||||
onPressed: busy ? null : onReject,
|
||||
),
|
||||
: Row(
|
||||
children: [
|
||||
_ClearButton(onTap: busy ? null : onClear),
|
||||
SizedBox(width: 2.w),
|
||||
// The running count, in words — but only when there is
|
||||
// room for it. With two decisions on the bar there is
|
||||
// not: at 320pt the three fixed controls plus this line
|
||||
// overflowed by 94pt, and "Accept 3" already carries the
|
||||
// number. So the sentence is a luxury for the one-button
|
||||
// case and the space goes to the buttons otherwise.
|
||||
if (onReject == null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$count ${count == 1 ? 'stop' : 'stops'} selected',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
SizedBox(width: 6.w),
|
||||
// ── Both decisions, on the bar ──
|
||||
//
|
||||
// Reject used to live on every stop row and went when the
|
||||
// rows became tick-only. That left the rider able to say
|
||||
// yes to a selection and nothing else: declining a stop he
|
||||
// could not do — shop shut, address wrong — had no route
|
||||
// at all, and the hub learned about it by the stop simply
|
||||
// never being accepted.
|
||||
//
|
||||
// So it comes back where Accept is, on the set he has
|
||||
// picked. Outlined against Accept's filled green: they are
|
||||
// not equal choices, and the one that ends work should not
|
||||
// look like the one that starts it.
|
||||
if (onReject != null) ...[
|
||||
// Capped, not flexed. [MilerButton] fills whatever box
|
||||
// it is given, so these are the widths the two labels
|
||||
// actually need — anything larger and the pair overflows
|
||||
// the bar on a 390pt phone, anything flexible and the
|
||||
// Spacer steals half.
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 104.w),
|
||||
child: MilerButton(
|
||||
label: 'Reject',
|
||||
variant: MilerButtonVariant.outlined,
|
||||
color: ColorConstants.errorRed,
|
||||
height: ButtonSizes.secondary,
|
||||
expand: false,
|
||||
onPressed: busy ? null : onReject,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
],
|
||||
// Content-hugging, so the count on the left keeps its room
|
||||
// on a narrow phone and the label never ellipsises — a
|
||||
// bulk action reading "Accept 3 sto…" is not one the rider
|
||||
// will trust.
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 142.w),
|
||||
child: MilerButton(
|
||||
label: 'Accept ($count)',
|
||||
icon: LucideIcons.checkCheck,
|
||||
color: ColorConstants.acceptGreen,
|
||||
height: ButtonSizes.secondary,
|
||||
expand: false,
|
||||
loading: busy,
|
||||
onPressed: onAccept,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
],
|
||||
// Content-hugging, so the count on the left keeps its room on a
|
||||
// narrow phone and the label never ellipsises — a bulk action
|
||||
// that reads "Accept 3 sto…" is not one the rider will trust.
|
||||
Flexible(
|
||||
child: MilerButton(
|
||||
label: 'Accept $count',
|
||||
icon: Icons.done_all_rounded,
|
||||
color: ColorConstants.acceptGreen,
|
||||
height: ButtonSizes.secondary,
|
||||
expand: false,
|
||||
loading: busy,
|
||||
onPressed: onAccept,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -191,7 +328,7 @@ class _ClearButton extends StatelessWidget {
|
||||
width: ButtonSizes.minTapTarget,
|
||||
height: ButtonSizes.minTapTarget,
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
LucideIcons.x,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
|
||||
815
lib/views/Dashboard/home/stop_action_sheet.dart
Normal file
815
lib/views/Dashboard/home/stop_action_sheet.dart
Normal file
@@ -0,0 +1,815 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
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';
|
||||
import 'package:miler/data/service_profile.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';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE RUNG SHEET — one deliberate gesture, at the place it describes.
|
||||
///
|
||||
/// ```
|
||||
/// ┌─────────────────────────────────┐
|
||||
/// │ ARRIVED AT VIDHYA KITCHEN │
|
||||
/// │ 3 orders to collect here │
|
||||
/// │ │
|
||||
/// │ ⟶ Slide to update │
|
||||
/// └─────────────────────────────────┘
|
||||
/// ```
|
||||
///
|
||||
/// ── Why a sheet and not a button on the row ──
|
||||
///
|
||||
/// Arriving and collecting are claims about the physical world: *I am at this
|
||||
/// counter*, *this bag is in my box*. They are written to the hub, they move
|
||||
/// money and food, and a stray thumb on a moving bike should not be able to
|
||||
/// make them. The rest of this app already reserves the slide gesture for
|
||||
/// exactly that class of statement, and a sheet is what gives the slide room
|
||||
/// and takes the rider's attention off the list for the second it takes.
|
||||
///
|
||||
/// The batch ladder on the selection bar does the same job for a set the rider
|
||||
/// ticked. This is the single-stop path: he is standing at one counter with one
|
||||
/// card open, and making him tick it first to reach a control that then acts on
|
||||
/// "the selection" is a detour around what he is looking at.
|
||||
///
|
||||
/// ── What it says ──
|
||||
///
|
||||
/// The heading names the **place**, not the status. "Arrived at Vidhya Kitchen"
|
||||
/// tells him what he is confirming; "Update status" tells him nothing he could
|
||||
/// be wrong about. Under it, the count of what that confirmation covers.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// What the rider actually confirmed, once the slide lands.
|
||||
///
|
||||
/// A rung used to be a yes/no: he slid, and every order in the batch moved. It
|
||||
/// is not, at a kitchen hatch — the counter hands over four bags out of five
|
||||
/// often enough that the app needs somewhere to say so, and the alternative was
|
||||
/// a *second* bulk flow with its own tick-list living beside this one. The two
|
||||
/// have been folded together: this sheet is the only place a pickup is
|
||||
/// confirmed, and this is what it reports back.
|
||||
class RungOutcome {
|
||||
/// Orders the counter could not supply. They do **not** move up the ladder —
|
||||
/// they leave the route with their own record. See [MilerApi] callers.
|
||||
final List<String> missingOrderIds;
|
||||
|
||||
/// One photo of the whole load, if he took one. Never per bag: a rider at a
|
||||
/// hatch with fifteen boxes cannot photograph each one, and the question
|
||||
/// anybody asks afterwards is "what was in the crate when it left?".
|
||||
final String? photoPath;
|
||||
|
||||
const RungOutcome({this.missingOrderIds = const [], this.photoPath});
|
||||
|
||||
bool get isClean => missingOrderIds.isEmpty;
|
||||
}
|
||||
|
||||
class StopActionSheet extends StatefulWidget {
|
||||
/// The rung being confirmed. Only [StopStatus.accepted] (→ arrived) and
|
||||
/// [StopStatus.arrived] (→ picked) open this sheet.
|
||||
final StopStatus stage;
|
||||
|
||||
/// Where he is — the kitchen for a pickup rung, the customer for a drop.
|
||||
final String placeName;
|
||||
|
||||
/// How many orders this confirmation covers at that place.
|
||||
final int count;
|
||||
|
||||
/// The orders this covers, each with the bag it travels in.
|
||||
///
|
||||
/// ── Why the sheet lists them ──
|
||||
///
|
||||
/// This is the moment the rider is standing at a counter with bags in front
|
||||
/// of him, and the only question that matters is *are these the right ones,
|
||||
/// and are they all here*. A number alone cannot be checked against a shelf;
|
||||
/// five named lines with a bag each can, and a missing sixth is visible
|
||||
/// before he rides away rather than at a door twenty minutes later.
|
||||
///
|
||||
/// Empty renders the sheet as it was — a heading, a count and the slide —
|
||||
/// which is correct for a rung that covers a single anonymous stop.
|
||||
final List<BagLine> lines;
|
||||
|
||||
/// Runs the write, given what the rider actually confirmed. Returns null on
|
||||
/// success, or a message to show without closing — the sheet stays open so he
|
||||
/// can retry from where he is.
|
||||
final Future<String?> Function(RungOutcome) onConfirm;
|
||||
|
||||
const StopActionSheet({
|
||||
super.key,
|
||||
required this.stage,
|
||||
required this.placeName,
|
||||
required this.count,
|
||||
required this.onConfirm,
|
||||
this.lines = const [],
|
||||
});
|
||||
|
||||
/// Opens the sheet and answers true once the rung was written.
|
||||
static Future<bool> show(
|
||||
BuildContext context, {
|
||||
required StopStatus stage,
|
||||
required String placeName,
|
||||
required int count,
|
||||
required Future<String?> Function(RungOutcome) onConfirm,
|
||||
List<BagLine> lines = const [],
|
||||
}) async {
|
||||
// Through the kit — which also gives this sheet the app's entrance curve.
|
||||
// It was the one sheet still on Flutter's default 250ms decelerate: the
|
||||
// most consequential confirmation in the app arrived with a pop while
|
||||
// everything around it rose.
|
||||
final done = await showMilerSheet<bool>(
|
||||
context,
|
||||
builder: (_) => StopActionSheet(
|
||||
stage: stage,
|
||||
placeName: placeName,
|
||||
count: count,
|
||||
onConfirm: onConfirm,
|
||||
lines: lines,
|
||||
),
|
||||
);
|
||||
return done ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
State<StopActionSheet> createState() => _StopActionSheetState();
|
||||
}
|
||||
|
||||
class _StopActionSheetState extends State<StopActionSheet> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
/// Orders the counter could not supply, by id.
|
||||
///
|
||||
/// Empty is the overwhelmingly common case and the sheet is built for it: the
|
||||
/// rider slides and leaves. Reporting a shortfall is a deliberate detour he
|
||||
/// takes only when the shelf disagrees with the list — see [_reporting].
|
||||
final Set<String> _missing = <String>{};
|
||||
|
||||
/// True once he has opened the shortfall list. The manifest is read-only
|
||||
/// until then: a tappable row on a screen he is only meant to *read* is a
|
||||
/// row he will mark by accident with a thumb on a moving bike.
|
||||
bool _reporting = false;
|
||||
|
||||
/// One photo of the whole load. Null until he takes it, and optional always.
|
||||
String? _photoPath;
|
||||
bool _takingPhoto = false;
|
||||
|
||||
/// How many are actually coming with him.
|
||||
int get _loaded => widget.count - _missing.length;
|
||||
|
||||
/// An empty crate is not a collection.
|
||||
///
|
||||
/// Marking every bag missing means the counter handed over nothing, and that
|
||||
/// is a wasted trip and a call to the hub — not a state to file silently. The
|
||||
/// slide goes dead and says so, which is the rule the collect sheet this
|
||||
/// replaced enforced with a disabled button.
|
||||
bool get _canSlide => _isArrival || _loaded > 0;
|
||||
|
||||
/// Whether the shortfall controls apply at all. Arrival is a claim about a
|
||||
/// place — he is either there or he is not — and nothing is handed over, so
|
||||
/// there is nothing to be short of.
|
||||
bool get _canReportMissing => !_isArrival && widget.lines.isNotEmpty;
|
||||
|
||||
bool get _isArrival => widget.stage == StopStatus.accepted;
|
||||
|
||||
/// Names the place, because that is the thing he can be wrong about.
|
||||
String get _title {
|
||||
final place = widget.placeName.trim();
|
||||
if (_isArrival) {
|
||||
return place.isEmpty ? 'Confirm arrival' : 'Arrived at $place';
|
||||
}
|
||||
return place.isEmpty ? 'Confirm pickup' : 'Picked up from $place';
|
||||
}
|
||||
|
||||
/// `5 orders · 5 bags` — the rule, stated where he can check it against the
|
||||
/// shelf. See [BagManifest]. Once he reports a shortfall it counts what he is
|
||||
/// actually leaving with, and names the gap rather than quietly shrinking.
|
||||
String get _subtitle => _missing.isEmpty
|
||||
? BagManifest.countLabel(widget.count)
|
||||
: '${BagManifest.countLabel(_loaded)} · ${_missing.length} missing';
|
||||
|
||||
/// What the slide will actually change, in the rider's words. "Slide to
|
||||
/// update" said nothing a rider could be wrong about; these name the claim —
|
||||
/// including the one where nothing was handed over at all.
|
||||
String get _slideLabel {
|
||||
if (_isArrival) return 'Slide to confirm arrival';
|
||||
if (!_canSlide) return 'Nothing to collect';
|
||||
return 'Slide to confirm pickup';
|
||||
}
|
||||
|
||||
/// The one instruction that belongs on a pickup: count the bags first.
|
||||
///
|
||||
/// ── Why it carries its own severity ──
|
||||
///
|
||||
/// Three different sentences come out of here and only two of them are
|
||||
/// exceptions. All three were painted amber, so the standing "count your
|
||||
/// bags" line — which shows on *every* clean pickup — arrived in the ink the
|
||||
/// app reserves for something genuinely wrong. A warning a rider sees every
|
||||
/// single time is one he stops seeing, and the two lines that really do need
|
||||
/// him had nothing left to say it with.
|
||||
///
|
||||
/// So each returns whether it is an exception, and the colour follows that
|
||||
/// rather than the slot.
|
||||
(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);
|
||||
}
|
||||
if (_missing.isEmpty) {
|
||||
return (
|
||||
'Make sure all ${widget.count} bags are loaded before continuing.',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return (
|
||||
'${_missing.length} of ${widget.count} not supplied — they will leave '
|
||||
'your route.',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
Color get _accent =>
|
||||
_isArrival ? ColorConstants.primary : ColorConstants.acceptGreen;
|
||||
|
||||
/// Drives the confirmation without the drag, for tests that pin the
|
||||
/// *payload* rather than the gesture. Named so it cannot be mistaken for a
|
||||
/// production entry point — the slide is the only way a rider reaches this.
|
||||
@visibleForTesting
|
||||
Future<void> confirmForTest() => _run();
|
||||
|
||||
Future<void> _run() async {
|
||||
if (_busy || !_canSlide) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final failure = await widget.onConfirm(
|
||||
RungOutcome(missingOrderIds: _missing.toList(), photoPath: _photoPath),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (failure != null) {
|
||||
// Held open on purpose. The most common failure here is the geofence
|
||||
// refusing because he is not close enough yet — and the answer to that is
|
||||
// to walk twenty metres and slide again, which he can only do if the
|
||||
// sheet is still in front of him.
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = failure;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MilerSheetScaffold(
|
||||
padding: EdgeInsets.fromLTRB(18.w, 0, 18.w, 16.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
|
||||
// giving the rider a way to reach the slide.
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38.w,
|
||||
height: 38.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isArrival ? LucideIcons.mapPin : LucideIcons.shoppingBag,
|
||||
size: 19.sp,
|
||||
color: _accent,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
height: 1.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3.h),
|
||||
Text(
|
||||
_subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_instruction case (final text, final exceptional)) ...[
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.35,
|
||||
color: exceptional
|
||||
? ColorConstants.warning
|
||||
: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.lines.isNotEmpty) ...[
|
||||
SizedBox(height: 14.h),
|
||||
_manifest(),
|
||||
],
|
||||
if (_canReportMissing) ...[SizedBox(height: 10.h), _reportToggle()],
|
||||
// One shot of the whole load, where the load is confirmed. It was
|
||||
// on a separate collect sheet; that sheet is gone and this is the
|
||||
// only place a pickup is now confirmed, so the proof comes with
|
||||
// it. Never blocking — see [_CratePhotoRow].
|
||||
if (!_isArrival && ServiceProfile.active.bulkLoadProof) ...[
|
||||
SizedBox(height: 10.h),
|
||||
_CratePhotoRow(
|
||||
path: _photoPath,
|
||||
busy: _takingPhoto || _busy,
|
||||
accent: _accent,
|
||||
onTake: _takePhoto,
|
||||
onClear: () => setState(() => _photoPath = null),
|
||||
),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
SizedBox(height: 14.h),
|
||||
InfoBanner(
|
||||
icon: LucideIcons.circleAlert,
|
||||
text: _error!,
|
||||
color: ColorConstants.errorRed,
|
||||
),
|
||||
],
|
||||
SizedBox(height: 18.h),
|
||||
_slider(),
|
||||
SizedBox(height: 6.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The way into reporting a shortfall, and the way back out.
|
||||
///
|
||||
/// ── Why this is a detour and not the default ──
|
||||
///
|
||||
/// The collect flow this replaced started with every bag *ticked* and asked
|
||||
/// the rider to untick what was missing. That is the right default — the
|
||||
/// crate is almost always right — but it made a checklist out of the common
|
||||
/// case: ten rows of controls on a screen whose answer was "yes" nine times
|
||||
/// out of ten, which is a screen riders learn to dismiss without reading.
|
||||
///
|
||||
/// So the common case is now a list he reads and a slide, and the exception
|
||||
/// is one tap away. The default is unchanged in meaning: nothing marked means
|
||||
/// everything came.
|
||||
Widget _reportToggle() {
|
||||
if (!_reporting) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _busy ? null : () => setState(() => _reporting = true),
|
||||
icon: Icon(
|
||||
LucideIcons.triangleAlert,
|
||||
size: 18.sp,
|
||||
color: ColorConstants.errorRed,
|
||||
),
|
||||
label: Text(
|
||||
'Report missing bag',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.errorRed,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_missing.isEmpty
|
||||
? 'Tap any bag the counter could not supply'
|
||||
: '${_missing.length} marked missing',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => setState(() {
|
||||
_reporting = false;
|
||||
_missing.clear();
|
||||
}),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// ── Camera, not gallery ──
|
||||
///
|
||||
/// The photo is evidence of a moment: this crate, at this counter, now.
|
||||
/// `ImageSource.gallery` would let a picture taken anywhere at any time stand
|
||||
/// in for that, which is worse than having no photo at all — it looks like
|
||||
/// proof and is not. Quality is capped because it travels over a rider's
|
||||
/// mobile data and nobody is zooming in on a rice box.
|
||||
Future<void> _takePhoto() async {
|
||||
if (_takingPhoto) return;
|
||||
setState(() => _takingPhoto = true);
|
||||
try {
|
||||
final shot = await ImagePicker().pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 70,
|
||||
maxWidth: 1280,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (shot != null) setState(() => _photoPath = shot.path);
|
||||
} catch (e) {
|
||||
debugPrint('[LOAD] Could not take crate photo: $e');
|
||||
if (mounted) {
|
||||
AppFeedback.error(
|
||||
context,
|
||||
"Couldn't open the camera — carry on without it",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _takingPhoto = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The orders this rung covers, one line each: who it is for, and the bag it
|
||||
/// travels in.
|
||||
///
|
||||
/// Capped in height rather than paged: a kitchen handing over twenty bags is
|
||||
/// rare and a sheet that grows past the fold is worse than one that scrolls.
|
||||
/// It is a list, not a checklist — ticking twenty boxes at a counter is the
|
||||
/// itemised load sheet's job, and this rung is one physical handover.
|
||||
Widget _manifest() {
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: 260.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
||||
itemCount: widget.lines.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 10.h),
|
||||
itemBuilder: (context, i) {
|
||||
final line = widget.lines[i];
|
||||
final gone = _missing.contains(line.orderId);
|
||||
|
||||
return InkWell(
|
||||
onTap: _reporting && !_busy
|
||||
? () => setState(() {
|
||||
gone
|
||||
? _missing.remove(line.orderId)
|
||||
: _missing.add(line.orderId);
|
||||
})
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
child: Row(
|
||||
children: [
|
||||
// The mark only appears while he is reporting: until then this is
|
||||
// a list to read against a shelf, not a form to fill in.
|
||||
if (_reporting) ...[
|
||||
Icon(
|
||||
gone ? LucideIcons.circleMinus : LucideIcons.circleCheck,
|
||||
size: 20.sp,
|
||||
color: gone
|
||||
? ColorConstants.errorRed
|
||||
: ColorConstants.borderStrong,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
// The position, so a rider counting down a shelf can keep his
|
||||
// place without re-reading the names.
|
||||
SizedBox(
|
||||
width: 18.w,
|
||||
child: Text(
|
||||
'${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
line.customer.isEmpty
|
||||
? 'Order ${line.orderId}'
|
||||
: line.customer,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: gone
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.slateText,
|
||||
decoration: gone ? TextDecoration.lineThrough : null,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
// The same tag the route rows wear — one language for one
|
||||
// fact, from Home to the sheet it opens. White stands on the
|
||||
// manifest's tinted panel; a missing bag keeps the error ink
|
||||
// in the same body so the exception is the colour, not the
|
||||
// shape.
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: gone
|
||||
? ColorConstants.errorRed.withValues(alpha: 0.08)
|
||||
: MilerSurface.working,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
gone ? 'Missing' : line.bag,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
color: gone
|
||||
? ColorConstants.errorRed
|
||||
: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 (_) {}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate photo control — a row, not a section.
|
||||
///
|
||||
/// ── Optional on purpose ──
|
||||
///
|
||||
/// A required photo is a rider standing at a counter unable to record what he
|
||||
/// is holding because the camera permission was declined nine weeks ago, or the
|
||||
/// hatch is dark, or the phone is at 2%. The load is the thing that must be
|
||||
/// recorded; the picture is what makes a later dispute cheap. So it is offered
|
||||
/// prominently and never blocks the button — which is also exactly what the app
|
||||
/// this flow came from did with its bulk proof.
|
||||
class _CratePhotoRow extends StatelessWidget {
|
||||
final String? path;
|
||||
final bool busy;
|
||||
final Color accent;
|
||||
final VoidCallback onTake;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const _CratePhotoRow({
|
||||
required this.path,
|
||||
required this.busy,
|
||||
required this.accent,
|
||||
required this.onTake,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final taken = path != null;
|
||||
|
||||
return InkWell(
|
||||
onTap: busy ? null : (taken ? onClear : onTake),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
|
||||
decoration: BoxDecoration(
|
||||
color: taken
|
||||
? ColorConstants.acceptGreen.withValues(alpha: 0.08)
|
||||
: ColorConstants.neutralLight,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// The shot itself once there is one: a thumbnail is the only
|
||||
// confirmation that answers "did it actually capture the crate,
|
||||
// or my shoe?" — a tick beside the word "Photo" does not.
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
child: SizedBox(
|
||||
width: 38.w,
|
||||
height: 38.w,
|
||||
child: taken
|
||||
? Image.file(File(path!), fit: BoxFit.cover)
|
||||
: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.camera,
|
||||
size: 19.sp,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 11.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
taken ? 'Crate photographed' : 'Photograph the crate',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: taken
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.slateText,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 1.h),
|
||||
Text(
|
||||
taken
|
||||
? 'Tap to retake'
|
||||
: 'Optional · one shot for the load',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
if (busy)
|
||||
SizedBox(
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: accent),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
taken ? LucideIcons.rotateCw : LucideIcons.chevronRight,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
977
lib/views/Dashboard/home/stop_card.dart
Normal file
977
lib/views/Dashboard/home/stop_card.dart
Normal file
@@ -0,0 +1,977 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.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';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// STOP CARD — one order, drawn as the journey it is.
|
||||
///
|
||||
/// ```
|
||||
/// ✓ ACCEPTED ~5 min · by 12:30 PM ( )
|
||||
///
|
||||
/// ● 🍴 PICKUP
|
||||
/// │ Vidhya Kitchen
|
||||
/// │ 📍 Ritham Tours & Travels 20, Gandhi nagar, Peelamedu…
|
||||
/// │ Quantity 1
|
||||
/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
|
||||
/// ● 📦 DROP
|
||||
/// Joe
|
||||
/// 📍 12, SNS Colony, Peelamedu, Coimbatore
|
||||
/// 🏢 Daily Grubs
|
||||
/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
|
||||
/// 🧾 Stop 1 · #916-2024116374 [📞] [ⓘ]
|
||||
///
|
||||
/// ┌───────────────────────────────────────────────────────┐
|
||||
/// │ ➤ Navigate to pickup │
|
||||
/// └───────────────────────────────────────────────────────┘
|
||||
/// ```
|
||||
///
|
||||
/// ── The three rules this card is built on ──
|
||||
///
|
||||
/// 1. **No boxes.** One pane of glass ([GlassCard]) with no border, and
|
||||
/// nothing bordered inside it. Everything that used to be a box — the type
|
||||
/// chip, the state panel, the reserved control slots, the tinted meta
|
||||
/// tags — is type and colour on plain rows now.
|
||||
///
|
||||
/// 2. **The rider reads this at arm's length, in sun, on a bike.** So the
|
||||
/// things he actually reads are large: the place name at 19sp, the address
|
||||
/// at 14.5, the time he is working to at 13.5. Nothing on this card is
|
||||
/// under 11.5.
|
||||
///
|
||||
/// 3. **One state, one next action.** The card always says where the order is
|
||||
/// (the chip, top left) and offers exactly one thing to press for it
|
||||
/// (bottom). An accepted order's one action is *get me there* — see
|
||||
/// [_primaryAction].
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class StopCard extends StatelessWidget {
|
||||
final Map<String, dynamic> stop;
|
||||
|
||||
/// 1-based position in the route, printed in the footer beside the order id.
|
||||
final int index;
|
||||
|
||||
final StopState state;
|
||||
|
||||
/// The pickup-side address, already shortened by the trip if the design's
|
||||
/// tail-trimming is on.
|
||||
final String address;
|
||||
|
||||
/// Time at the door, shown in the card's header.
|
||||
final Duration service;
|
||||
|
||||
/// Which bag this order travels in — `Bag 3`, or the label the kitchen
|
||||
/// printed. One order, one bag; the count is never a separate figure. See
|
||||
/// [BagManifest].
|
||||
final String bag;
|
||||
|
||||
/// True when this card sits under a heading that already names its pickup
|
||||
/// location.
|
||||
///
|
||||
/// It changes what the card leads with. Under a kitchen heading, five cards
|
||||
/// each headlined "Vidhya Kitchen" is the same name six times on one screen —
|
||||
/// the heading, then once per card — and the one thing that actually tells
|
||||
/// the five apart, the customer, was demoted to a line underneath. So a
|
||||
/// grouped card leads with **who it is for** and shows the address it is
|
||||
/// going to, which is also how the kitchen's own manifest reads.
|
||||
final bool groupedUnderSource;
|
||||
|
||||
final bool selected;
|
||||
|
||||
/// True while this stop's acceptance is on the wire — the tick goes dead so
|
||||
/// it cannot disagree with the request in flight.
|
||||
final bool busy;
|
||||
|
||||
/// Opens the detail sheet. The whole card carries it, and so does the ⓘ tile.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Null hides the tick entirely — see the note in [TripCard] on which states
|
||||
/// are still choosable.
|
||||
final VoidCallback? onToggleSelect;
|
||||
|
||||
final VoidCallback? onCall;
|
||||
|
||||
/// Opens turn-by-turn navigation to whichever leg this stop is on. This is
|
||||
/// the accepted card's primary action, not a tile in a corner: an accepted
|
||||
/// order's whole content is "go to this kitchen".
|
||||
final VoidCallback? onNavigate;
|
||||
|
||||
/// Returns a rejected stop to undecided.
|
||||
final VoidCallback? onUnreject;
|
||||
|
||||
/// Takes the rider back into the stop he is already working.
|
||||
final VoidCallback? onContinue;
|
||||
|
||||
/// The next rung for a stop already under way, behind the same slide sheet
|
||||
/// the selection bar uses. Only ever offered on an *arrived* stop — the rung
|
||||
/// before that is reached by navigating there and ticking the counter's
|
||||
/// orders. See [_primaryAction].
|
||||
final VoidCallback? onAdvance;
|
||||
final String? advanceLabel;
|
||||
|
||||
const StopCard({
|
||||
super.key,
|
||||
required this.stop,
|
||||
required this.index,
|
||||
required this.state,
|
||||
required this.address,
|
||||
required this.service,
|
||||
this.bag = '',
|
||||
this.groupedUnderSource = false,
|
||||
this.selected = false,
|
||||
this.busy = false,
|
||||
this.onTap,
|
||||
this.onToggleSelect,
|
||||
this.onCall,
|
||||
this.onNavigate,
|
||||
this.onUnreject,
|
||||
this.onContinue,
|
||||
this.onAdvance,
|
||||
this.advanceLabel,
|
||||
});
|
||||
|
||||
bool get _live => state == StopState.active;
|
||||
bool get _rejected => state == StopState.rejected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kind = stopKindOf(stop);
|
||||
|
||||
final source = stopSourceName(stop);
|
||||
final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop')
|
||||
.toString();
|
||||
final drop = (stop['dropaddress'] ?? stop['DropAddress'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
// ── The card leads with where he is going ──
|
||||
//
|
||||
// Not with both ends of the journey. A meal order has two, and drawing both
|
||||
// at equal weight — two avatars, two labels, two addresses joined by a
|
||||
// thread — made every card a diagram of a journey rather than an answer to
|
||||
// "where am I going and what do I do there". The destination takes the
|
||||
// headline; the other end drops to one supporting line underneath.
|
||||
//
|
||||
// Before pickup the destination is the counter, because that is the address
|
||||
// he is riding to. After it, the drop leads — and that card lives on the
|
||||
// work tab, where [PickupCard] does exactly the same thing in the other
|
||||
// direction.
|
||||
final twoLeg = ServiceProfile.active.deliversToCustomer && drop.isNotEmpty;
|
||||
final grouped = groupedUnderSource && source.isNotEmpty;
|
||||
final headline = (twoLeg && !grouped && source.isNotEmpty)
|
||||
? source
|
||||
: customer;
|
||||
final headlineAddress = grouped && drop.isNotEmpty ? drop : address;
|
||||
|
||||
final action = _primaryAction();
|
||||
|
||||
return GlassCard(
|
||||
margin: EdgeInsets.only(bottom: 12.h),
|
||||
onTap: onTap,
|
||||
opacity: _rejected ? 0.6 : 1,
|
||||
tint: _live ? ColorConstants.glassCardLive : null,
|
||||
child: Padding(
|
||||
// One inset for the whole card, and the button spans it. The card used
|
||||
// to run four different paddings — a header inset, a leg inset, a
|
||||
// footer inset and a CTA inset — which is what made the left edge of
|
||||
// the content wander down the card.
|
||||
padding: EdgeInsets.fromLTRB(18.w, 16.h, 18.w, 16.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_statusRow(kind),
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── The headline ──
|
||||
//
|
||||
// The loudest thing on the card, by a clear step. It is
|
||||
// what the rider shouts at a gate and what he checks a
|
||||
// shop sign against, and at 19sp beside a 16sp address it
|
||||
// was not winning by enough to be found without reading.
|
||||
Text(
|
||||
headline,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 21.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.12,
|
||||
letterSpacing: -0.6,
|
||||
color: ColorConstants.slateText,
|
||||
decoration: _rejected
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
if (headlineAddress.isNotEmpty) ...[
|
||||
SizedBox(height: 5.h),
|
||||
Text(
|
||||
headlineAddress,
|
||||
maxLines: kFullAddressTrial ? 3 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Ringing ahead is the one thing a rider does from a list that
|
||||
// is not "go there", so it keeps a place on the card — a single
|
||||
// quiet glyph, not one of a row of tiles.
|
||||
if (onCall != null) ...[
|
||||
SizedBox(width: 10.w),
|
||||
_IconAction(
|
||||
icon: LucideIcons.phone,
|
||||
semanticLabel: 'Call this stop',
|
||||
onTap: onCall!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (twoLeg && !grouped) ...[
|
||||
SizedBox(height: 12.h),
|
||||
_dropLine(customer),
|
||||
],
|
||||
SizedBox(height: 12.h),
|
||||
_factsRow(
|
||||
kind,
|
||||
bagShownAbove: twoLeg && !grouped && bag.isNotEmpty,
|
||||
),
|
||||
if (action != null) ...[SizedBox(height: 16.h), action],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A quantity of at least one: a stop that carries nothing is not a stop, and
|
||||
/// a payload that forgot to say so should not print "0".
|
||||
int _qty(int v) => v > 0 ? v : 1;
|
||||
|
||||
/// The type, the state and the tick, on one line above the name.
|
||||
///
|
||||
/// All three are *marks*: small, quiet, and never competing with the address
|
||||
/// underneath. The state chip is the only one that carries colour, because it
|
||||
/// is the only one that changes.
|
||||
Widget _statusRow(StopKind kind) {
|
||||
final chip = _live
|
||||
? const LiveMark()
|
||||
: (state == StopState.pending &&
|
||||
!ServiceProfile.active.handsOffAtCollection)
|
||||
? null
|
||||
: StopStateChip(state: state, filled: true);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
kind.chipLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.0,
|
||||
// A mark, in the type's own colour — never a fill and never a
|
||||
// border. See `stop_card_neutral_test`, which pins exactly that.
|
||||
color: kind.chipColor,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
if (chip != null) Flexible(child: chip),
|
||||
const Spacer(),
|
||||
if (onToggleSelect != null)
|
||||
SelectBox(
|
||||
selected: selected,
|
||||
accent: ColorConstants.primary,
|
||||
label: 'Select stop $index',
|
||||
circular: true,
|
||||
// Dead while this stop's acceptance is on the wire: un-ticking it
|
||||
// there would leave the tick and the request disagreeing.
|
||||
onTap: busy ? null : onToggleSelect,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// `→ Joe · Bag 1` — the far end, in one line.
|
||||
///
|
||||
/// Everything the second leg used to spend a whole block on: who it is for,
|
||||
/// and which bag it is. The address is one tap away in the detail sheet,
|
||||
/// where a rider going to a *kitchen* has no use for it yet.
|
||||
Widget _dropLine(String customer) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowRight,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.deliveryChip,
|
||||
),
|
||||
SizedBox(width: 7.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
customer,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bag.isNotEmpty) ...[SizedBox(width: 10.w), _BagChip(label: bag)],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The figures, on one line: time at the door, when it is due, what is in it.
|
||||
///
|
||||
/// This is the line a rider re-reads at a red light, so nothing on it is
|
||||
/// under 14sp and none of it is in a box.
|
||||
Widget _factsRow(StopKind kind, {required bool bagShownAbove}) {
|
||||
final due = DateTime.tryParse(
|
||||
(stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(),
|
||||
);
|
||||
final cash = stopCollectionAmount(stop);
|
||||
|
||||
final parts = <String>[
|
||||
if (service > Duration.zero) '~${formatTripDuration(service)}',
|
||||
if (due != null) 'by ${RouteMetricsHelper.formatClock(due)}',
|
||||
// Position in the route. The trip's own index, which does not renumber
|
||||
// itself as stops leave the screen.
|
||||
'Stop $index',
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.clock,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 7.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
parts.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// What is in it, and which bag it is. The bag appears here whenever the
|
||||
// drop line above is not already carrying it — a card must never be
|
||||
// silent about which box this order is, and it must never say it twice.
|
||||
Builder(
|
||||
builder: (_) {
|
||||
final label = bag.isNotEmpty ? bag : stopBagLabel(stop);
|
||||
final meta = _MetaLine(
|
||||
deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0,
|
||||
collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0,
|
||||
cash: cash,
|
||||
muted: _rejected || state == StopState.done,
|
||||
// On a meal run the parcel counts are the one-bag rule restated
|
||||
// as an item count, which is exactly the second number this app
|
||||
// does not allow. Only the money survives.
|
||||
countsHidden: ServiceProfile.active.deliversToCustomer,
|
||||
);
|
||||
final showBag = !bagShownAbove && label.isNotEmpty;
|
||||
if (!showBag && meta.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 8.h),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!meta.isEmpty) Flexible(child: meta),
|
||||
if (showBag) ...[
|
||||
if (!meta.isEmpty) SizedBox(width: 10.w),
|
||||
_BagChip(label: label),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The one thing to press on this card, sized like it matters.
|
||||
///
|
||||
/// ── Why an accepted order's action is "navigate" ──
|
||||
///
|
||||
/// Accepting is a promise; the next physical act is riding to the counter.
|
||||
/// The rung after that — *I have arrived* — is a claim about a place, and it
|
||||
/// is made from the selection bar with every order from that counter ticked,
|
||||
/// because he arrives at a kitchen, not at an order. So the card offers the
|
||||
/// journey and the bar offers the claim, and neither duplicates the other.
|
||||
///
|
||||
/// A stop that is already *arrived* is the one exception: the single-order
|
||||
/// case ("one bag, one counter") would otherwise make him tick a card he is
|
||||
/// looking at to reach a control that talks about "the selection". That
|
||||
/// button opens the same slide sheet the bar does, over the same batch.
|
||||
Widget? _primaryAction() {
|
||||
Widget wrap(Widget child) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 16.h),
|
||||
child: SizedBox(width: double.infinity, child: child),
|
||||
);
|
||||
|
||||
if (_rejected) {
|
||||
return onUnreject == null
|
||||
? null
|
||||
: wrap(
|
||||
MilerButton(
|
||||
label: 'Undo reject',
|
||||
icon: LucideIcons.undo2,
|
||||
variant: MilerButtonVariant.outlined,
|
||||
color: ColorConstants.slateText,
|
||||
height: ButtonSizes.secondary,
|
||||
loading: busy,
|
||||
onPressed: busy ? null : onUnreject,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The stop under way, on a parcel route: back into the verification flow.
|
||||
if (_live && onContinue != null) {
|
||||
return wrap(
|
||||
MilerButton(
|
||||
label: stopKindOf(stop).isDelivery
|
||||
? 'Continue delivery'
|
||||
: 'Continue pickup',
|
||||
icon: LucideIcons.arrowRight,
|
||||
color: ColorConstants.primary,
|
||||
height: ButtonSizes.primary,
|
||||
onPressed: onContinue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The stop under way, on a service route: the next rung, over the batch at
|
||||
// this counter.
|
||||
if (_live && onAdvance != null && advanceLabel != null) {
|
||||
return wrap(
|
||||
MilerButton(
|
||||
label: advanceLabel!,
|
||||
icon: LucideIcons.shoppingBag,
|
||||
color: ColorConstants.acceptGreen,
|
||||
height: ButtonSizes.primary,
|
||||
loading: busy,
|
||||
onPressed: busy ? null : onAdvance,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Under way, with nothing on this screen able to move it on: say where it
|
||||
// *is* worked. A sentence, not a button — a control that cannot be pressed
|
||||
// is not a status, and this is the one case where the card has nothing to
|
||||
// offer but wayfinding.
|
||||
if (_live) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 16.h),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.bike, size: 17.sp, color: ColorConstants.primary),
|
||||
SizedBox(width: 8.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'In progress — finish it in '
|
||||
'${ServiceProfile.active.workTabLabel}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Taken on, and not yet there.
|
||||
if (state == StopState.accepted && onNavigate != null) {
|
||||
return wrap(
|
||||
MilerButton(
|
||||
label: 'Navigate to pickup',
|
||||
icon: LucideIcons.navigation,
|
||||
// Maroon, not green: across this app green *advances the job* and
|
||||
// brand red is navigation and identity. Setting off changes nothing
|
||||
// at the hub, so it must not wear the colour that does.
|
||||
color: ColorConstants.primary,
|
||||
height: ButtonSizes.primary,
|
||||
onPressed: onNavigate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A quiet round glyph button — the card's only icon control.
|
||||
///
|
||||
/// One, not a row of three. The footer used to carry call, navigate and
|
||||
/// details as equal tiles; navigating is the card's primary button now and the
|
||||
/// details are the card's own tap, which leaves exactly one thing worth a
|
||||
/// glyph.
|
||||
class _IconAction extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String semanticLabel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _IconAction({
|
||||
required this.icon,
|
||||
required this.semanticLabel,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: semanticLabel,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: 48.w,
|
||||
height: 48.w,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 42.w,
|
||||
height: 42.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 20.sp, color: ColorConstants.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Bag 3` — the one code the rider matches against a box in his hand.
|
||||
///
|
||||
/// A neutral chip, not a coloured one: it is an identifier, not a state, and
|
||||
/// the card has exactly one colour to spend. It is a chip rather than plain
|
||||
/// text because it is read *against an object* — a bordered word is findable on
|
||||
/// a card at arm's length in a way a run of grey text is not.
|
||||
class _BagChip extends StatelessWidget {
|
||||
final String label;
|
||||
const _BagChip({required this.label});
|
||||
|
||||
/// Backend labels are codes — `DG-1042`, `BAG-1` — and derived ones already
|
||||
/// read `Bag 3`. The word belongs on both, and the test for "already has it"
|
||||
/// is the *word*, not the prefix: `BAG-1` starts with those three letters and
|
||||
/// is still a code, so it becomes `Bag BAG-1` exactly as it always read.
|
||||
String get _text =>
|
||||
label.toLowerCase().startsWith('bag ') ? label : 'Bag $label';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Text(
|
||||
_text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tick box for selecting a stop.
|
||||
///
|
||||
/// Square on the select-all strip, circular on a card — a card's tick sits
|
||||
/// beside a round avatar and a round dot, and a square there was the only
|
||||
/// corner in that column.
|
||||
class SelectBox extends StatelessWidget {
|
||||
final bool selected;
|
||||
final bool partial;
|
||||
final Color accent;
|
||||
final String label;
|
||||
final bool circular;
|
||||
|
||||
/// Null while the stop's acceptance is in flight.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SelectBox({
|
||||
super.key,
|
||||
required this.selected,
|
||||
required this.accent,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.partial = false,
|
||||
this.circular = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final on = selected || partial;
|
||||
return Semantics(
|
||||
checked: selected,
|
||||
label: label,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// 48dp hit area around a 28px box: the visible control stays compact
|
||||
// but the target clears the accessibility floor comfortably. This is
|
||||
// the control the rider hits most on this screen, one-handed, in
|
||||
// motion — it is worth the four extra points over the 44 floor.
|
||||
child: Container(
|
||||
width: 48.w,
|
||||
height: 48.w,
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
width: 28.w,
|
||||
height: 28.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? accent : ColorConstants.pureSurface,
|
||||
shape: circular ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: circular
|
||||
? null
|
||||
: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
border: Border.all(
|
||||
color: on ? accent : ColorConstants.borderStrong,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: on
|
||||
? Icon(
|
||||
partial ? LucideIcons.minus : LucideIcons.check,
|
||||
size: 18.sp,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `● Active` — the stop the rider is on, with a dot that breathes.
|
||||
///
|
||||
/// The word was `LIVE`, which is broadcast vocabulary: it says a stream is
|
||||
/// on air, not that a rider is standing at a counter. **Active** is the word
|
||||
/// the rest of the app already uses for this rung — the trip tab's status
|
||||
/// line, the queue's own heading, `StopStatus.active` itself — so the one
|
||||
/// state the rider must not miss now has one name everywhere he meets it.
|
||||
///
|
||||
/// Movement is what makes "this one is running right now" read without being
|
||||
/// read; a static dot beside the word is a claim the interface does not back
|
||||
/// up.
|
||||
///
|
||||
/// The controller is created on first *use* rather than on the declaration, so
|
||||
/// 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});
|
||||
|
||||
@override
|
||||
State<LiveMark> createState() => _LiveMarkState();
|
||||
}
|
||||
|
||||
class _LiveMarkState extends State<LiveMark>
|
||||
with SingleTickerProviderStateMixin {
|
||||
AnimationController? _controller;
|
||||
|
||||
AnimationController get _pulse => _controller ??= AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = ColorConstants.primary;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulse,
|
||||
builder: (context, _) {
|
||||
// A halo that grows and fades around a dot that stays put:
|
||||
// scaling the dot itself would shift the word beside it every
|
||||
// frame.
|
||||
return SizedBox(
|
||||
width: 8.w,
|
||||
height: 8.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Transform.scale(
|
||||
scale: 1.0 + 1.1 * _pulse.value,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(
|
||||
alpha: 0.55 * (1 - _pulse.value),
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: SizedBox(width: 8.w, height: 8.w),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 7.w,
|
||||
height: 7.w,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Active',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.2,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where this order stands, named in one word.
|
||||
///
|
||||
/// [filled] gives it the tinted pill it wears at the top of a card, where it is
|
||||
/// the first thing read; inline uses — anywhere it is one fact among others —
|
||||
/// keep the bare icon-and-word, because a pill in a paragraph is a box.
|
||||
class StopStateChip extends StatelessWidget {
|
||||
final StopState state;
|
||||
final bool filled;
|
||||
|
||||
const StopStateChip({super.key, required this.state, this.filled = false});
|
||||
|
||||
({Color color, IconData icon}) get _look => switch (state) {
|
||||
StopState.accepted => (
|
||||
color: ColorConstants.acceptGreen,
|
||||
icon: LucideIcons.circleCheck,
|
||||
),
|
||||
// In his hands. The service accent rather than green: green is the app's
|
||||
// "done" colour everywhere else, and a collected order is the opposite of
|
||||
// done — it is the point at which he owes somebody a delivery.
|
||||
StopState.collected => (
|
||||
color: ColorConstants.serviceAccent,
|
||||
icon: LucideIcons.shoppingBag,
|
||||
),
|
||||
StopState.active => (color: ColorConstants.primary, icon: LucideIcons.bike),
|
||||
StopState.done => (
|
||||
color: ColorConstants.acceptGreen,
|
||||
icon: LucideIcons.badgeCheck,
|
||||
),
|
||||
StopState.skipped => (
|
||||
color: ColorConstants.warning,
|
||||
icon: LucideIcons.rotateCcw,
|
||||
),
|
||||
StopState.rejected => (
|
||||
color: ColorConstants.secondaryText,
|
||||
icon: LucideIcons.ban,
|
||||
),
|
||||
StopState.pending => (
|
||||
color: ColorConstants.secondaryText,
|
||||
icon: LucideIcons.clock,
|
||||
),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final look = _look;
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(look.icon, size: filled ? 15.sp : 13.sp, color: look.color),
|
||||
SizedBox(width: 6.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
state.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: filled ? 12.5.sp : 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
color: look.color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (!filled) return row;
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: look.color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── The work at this stop, on one line ──
|
||||
///
|
||||
/// Plain text rather than tinted chips. The rule for a figure that has to
|
||||
/// survive a glance is weight and contrast, not a container; three containers
|
||||
/// on one row was the opposite of that, and on a combined stop they wrapped
|
||||
/// onto a second line and made the card taller than its neighbours.
|
||||
///
|
||||
/// Only the cash is coloured. Parcel counts are labels; the money is a state
|
||||
/// the rider is accountable for, and green means money throughout the app.
|
||||
class _MetaLine extends StatelessWidget {
|
||||
final int deliver;
|
||||
final int collect;
|
||||
final double cash;
|
||||
final bool muted;
|
||||
|
||||
/// Suppresses the parcel counts, leaving only money. See the call site.
|
||||
final bool countsHidden;
|
||||
|
||||
const _MetaLine({
|
||||
required this.deliver,
|
||||
required this.collect,
|
||||
required this.cash,
|
||||
this.muted = false,
|
||||
this.countsHidden = false,
|
||||
});
|
||||
|
||||
/// True when there is nothing to say, so the caller can skip its own spacing
|
||||
/// rather than laying out a gap around a zero-height widget.
|
||||
bool get isEmpty =>
|
||||
cash <= 0 && (countsHidden || (deliver <= 0 && collect <= 0));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
TextStyle style(Color c) => TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: c,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
);
|
||||
|
||||
final dot = TextSpan(
|
||||
text: ' · ',
|
||||
style: style(ColorConstants.borderStrong),
|
||||
);
|
||||
|
||||
final body = muted
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.slateText;
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
if (deliver > 0 && !countsHidden) {
|
||||
spans.add(TextSpan(text: 'Deliver $deliver', style: style(body)));
|
||||
}
|
||||
if (collect > 0 && !countsHidden) {
|
||||
if (spans.isNotEmpty) spans.add(dot);
|
||||
spans.add(TextSpan(text: 'Collect $collect', style: style(body)));
|
||||
}
|
||||
if (cash > 0) {
|
||||
if (spans.isNotEmpty) spans.add(dot);
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: '₹${_trim(cash)} cash',
|
||||
style: style(ColorConstants.moneyGreen),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (spans.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(children: spans),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rupee amounts without trailing noise: `1030`, `40`, `12.5`.
|
||||
String _trim(double v) {
|
||||
if (v >= 100) return v.round().toString();
|
||||
final rounded = (v * 10).round() / 10;
|
||||
return rounded == rounded.roundToDouble()
|
||||
? rounded.round().toString()
|
||||
: rounded.toStringAsFixed(1);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
import 'package:miler/views/helpers/widgets/miler_map.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
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/page_transitions.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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -43,12 +47,23 @@ class StopDetailSheet extends StatelessWidget {
|
||||
final double? riderLat;
|
||||
final double? riderLng;
|
||||
|
||||
/// The bag this order travels in, as the caller's store recorded it at the
|
||||
/// counter. Empty falls back to the payload's own `baglabel`, and a line
|
||||
/// with neither simply has no Bag row — nothing is invented.
|
||||
final String bag;
|
||||
|
||||
/// Orders the rider is carrying — what turns a raw `pickup` row into the
|
||||
/// delivery leg. Empty (Home's caller) leaves the raw kind in charge.
|
||||
final Set<String> collectedIds;
|
||||
|
||||
const StopDetailSheet({
|
||||
super.key,
|
||||
required this.stop,
|
||||
required this.stopNumber,
|
||||
this.riderLat,
|
||||
this.riderLng,
|
||||
this.bag = '',
|
||||
this.collectedIds = const {},
|
||||
});
|
||||
|
||||
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
|
||||
@@ -56,22 +71,21 @@ class StopDetailSheet extends StatelessWidget {
|
||||
BuildContext context, {
|
||||
required Map<String, dynamic> stop,
|
||||
required int stopNumber,
|
||||
String bag = '',
|
||||
Set<String> collectedIds = const {},
|
||||
double? riderLat,
|
||||
double? riderLng,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
// 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.
|
||||
sheetAnimationStyle: kMilerLargeSheetStyle,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
// 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.
|
||||
return showMilerSheet<void>(
|
||||
context,
|
||||
large: true,
|
||||
builder: (_) => StopDetailSheet(
|
||||
stop: stop,
|
||||
stopNumber: stopNumber,
|
||||
bag: bag,
|
||||
collectedIds: collectedIds,
|
||||
riderLat: riderLat,
|
||||
riderLng: riderLng,
|
||||
),
|
||||
@@ -94,15 +108,65 @@ class StopDetailSheet extends StatelessWidget {
|
||||
return '';
|
||||
}
|
||||
|
||||
/// The leg actually being worked, not the row's raw label.
|
||||
///
|
||||
/// `stopKindOf` reads the payload's `type`, which on a collected milk-run
|
||||
/// stop still says *pickup* — so this sheet was captioning the customer's
|
||||
/// door "PICKUP LOCATION / Collect 1 parcel" while its own CTA (driven by
|
||||
/// [MilkRun.navigatesToCustomer]) said "Navigate to customer". One sheet,
|
||||
/// two legs. [MilkRun.workingKind] is the ladder the card reads; the sheet
|
||||
/// now reads the same rung.
|
||||
StopKind get _kind => MilkRun.workingKind(stop, collectedIds: collectedIds);
|
||||
|
||||
/// Whether the job at this stop is the delivery leg — the one case where
|
||||
/// "where is this stop" is the drop, not the collection point.
|
||||
bool get _isDropLeg {
|
||||
final kind = _kind;
|
||||
return kind.hasDelivery && !kind.hasPickup;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kind = stopKindOf(stop);
|
||||
final lat = _d(stop['pickuplat'] ?? stop['PickupLat']);
|
||||
final lng = _d(stop['pickuplon'] ?? stop['PickupLon']);
|
||||
final kind = _kind;
|
||||
|
||||
// ── The address and the pin follow the LEG ────────────────────────────
|
||||
//
|
||||
// This sheet opens from two tabs. On Home every stop is a collection and
|
||||
// the pickup keys are the job; on Deliveries every stop is a drop — and
|
||||
// the sheet read `pickupaddress`/`pickuplat` regardless. So a rider
|
||||
// opening a drop's details met **`DELIVER TO` over the kitchen's street**,
|
||||
// a map pinned to the counter he had already left, and a distance line
|
||||
// measuring the ride back to it. Same family as the record's Route bug,
|
||||
// fixed the same way: the drop keys lead on a drop, and a payload that
|
||||
// carries only one address still shows that one.
|
||||
//
|
||||
// (`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]]);
|
||||
}
|
||||
|
||||
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 hasLocation = lat != 0 && lng != 0;
|
||||
|
||||
final name = _s(['pickupcustomer', 'tenantname']);
|
||||
final address = _s(['pickupaddress', 'PickupAddress']);
|
||||
final address = drop
|
||||
? _s([
|
||||
'dropaddress',
|
||||
'DropAddress',
|
||||
'deliveryaddress',
|
||||
'pickupaddress',
|
||||
'PickupAddress',
|
||||
])
|
||||
: _s(['pickupaddress', 'PickupAddress']);
|
||||
final phone = _s(['pickupcontactno']);
|
||||
final notes = _s(['notes', 'Notes']);
|
||||
final otp = stopOtp(stop);
|
||||
@@ -110,10 +174,14 @@ class StopDetailSheet extends StatelessWidget {
|
||||
final collect = pickupParcelCount(stop);
|
||||
final cash = stopCollectionAmount(stop);
|
||||
|
||||
final meters = RouteMetricsHelper.metersToStop(
|
||||
// Through the leg's own coordinates — `metersToStop` carries the leg flag
|
||||
// now, so the sheet and the cards compute the same number from the same
|
||||
// reader instead of two implementations agreeing by luck.
|
||||
final double? meters = RouteMetricsHelper.metersToStop(
|
||||
stop,
|
||||
riderLat: riderLat,
|
||||
riderLng: riderLng,
|
||||
toDrop: drop,
|
||||
);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
@@ -121,98 +189,105 @@ class StopDetailSheet extends StatelessWidget {
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => Column(
|
||||
children: [
|
||||
SizedBox(height: 10.h),
|
||||
Container(
|
||||
width: 40.w,
|
||||
height: 4.h,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.borderStrong,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h),
|
||||
children: [
|
||||
_header(kind, name),
|
||||
SizedBox(height: 14.h),
|
||||
if (hasLocation) ...[
|
||||
_map(lat, lng, kind),
|
||||
SizedBox(height: 10.h),
|
||||
_distanceLine(meters),
|
||||
SizedBox(height: 16.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.w600,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
// 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: [
|
||||
_header(kind, name),
|
||||
SizedBox(height: 14.h),
|
||||
if (hasLocation) ...[
|
||||
_map(lat, lng, kind),
|
||||
SizedBox(height: 10.h),
|
||||
_distanceLine(meters),
|
||||
SizedBox(height: 16.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
SizedBox(height: 16.h),
|
||||
],
|
||||
_sectionLabel('WHAT TO DO HERE'),
|
||||
SizedBox(height: 8.h),
|
||||
if (kind.hasDelivery)
|
||||
_actionRow(
|
||||
icon: LucideIcons.truck,
|
||||
color: ColorConstants.deliveryAccent,
|
||||
title: _countLabel('Deliver', deliver),
|
||||
detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof',
|
||||
),
|
||||
if (kind.hasPickup)
|
||||
_actionRow(
|
||||
icon: LucideIcons.package,
|
||||
color: ColorConstants.pickupAccent,
|
||||
title: _countLabel('Collect', collect),
|
||||
detail: cash > 0
|
||||
? 'Collect ₹${cash.round()}'
|
||||
: 'Photo + weight',
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
_detailGrid(),
|
||||
if (notes.isNotEmpty) ...[
|
||||
SizedBox(height: 16.h),
|
||||
_noteBox(notes),
|
||||
],
|
||||
SizedBox(height: 20.h),
|
||||
_actions(context, phone, lat, lng, hasLocation),
|
||||
],
|
||||
_sectionLabel('WHAT TO DO HERE'),
|
||||
SizedBox(height: 8.h),
|
||||
if (kind.hasDelivery)
|
||||
_actionRow(
|
||||
icon: Icons.local_shipping_rounded,
|
||||
color: ColorConstants.deliveryAccent,
|
||||
title:
|
||||
'Deliver ${deliver > 0 ? deliver : 1} parcel${deliver == 1 ? '' : 's'}',
|
||||
detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof',
|
||||
),
|
||||
if (kind.hasPickup)
|
||||
_actionRow(
|
||||
icon: Icons.inventory_2_rounded,
|
||||
color: ColorConstants.pickupAccent,
|
||||
title:
|
||||
'Collect ${collect > 0 ? collect : 1} parcel${collect == 1 ? '' : 's'}',
|
||||
detail: cash > 0
|
||||
? 'Collect ₹${cash.round()}'
|
||||
: 'Photo + weight',
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
_detailGrid(),
|
||||
if (notes.isNotEmpty) ...[
|
||||
SizedBox(height: 16.h),
|
||||
_noteBox(notes),
|
||||
],
|
||||
SizedBox(height: 20.h),
|
||||
_actions(context, phone, lat, lng, hasLocation),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// "Collect 1 parcel", "Deliver 3 parcels". The count that is *shown* picks
|
||||
/// the word — the old inline form defaulted a zero count to the figure 1
|
||||
/// while pluralising off the raw zero, and printed "Collect 1 parcels".
|
||||
static String _countLabel(String verb, int count) {
|
||||
final n = count > 0 ? count : 1;
|
||||
return '$verb $n parcel${n == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
Widget _header(StopKind kind, String name) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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.
|
||||
Container(
|
||||
width: 34.w,
|
||||
height: 34.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: kind.accent, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: kind.accent.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$stopNumber',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: kind.accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -222,35 +297,32 @@ class StopDetailSheet extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
||||
decoration: BoxDecoration(
|
||||
color: kind.accent,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Text(
|
||||
kind.badgeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
// ── The type chip is gone from the header ──
|
||||
//
|
||||
// A filled "MILK" / "DELIVERY" tab sat ABOVE the customer's
|
||||
// name — category before subject, in a solid service colour,
|
||||
// on the one surface where no card idiom shows that chip any
|
||||
// more. What the stop *is* the sheet already states twice, in
|
||||
// words: the WHAT TO DO HERE row and the grid's From/Bag
|
||||
// lines. The name leads now, as it does on every card.
|
||||
Text(
|
||||
name.isEmpty ? 'Stop $stopNumber' : name,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.15,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
// ── The one fact the sheet never stated ──
|
||||
//
|
||||
// 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -258,6 +330,34 @@ 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.
|
||||
///
|
||||
@@ -278,11 +378,6 @@ class StopDetailSheet extends StatelessWidget {
|
||||
/// is already paid for by [AfterEntrance] holding it back, so nothing is lost
|
||||
/// by dropping it.
|
||||
Widget _map(double lat, double lng, StopKind kind) {
|
||||
final stopPos = LatLng(lat, lng);
|
||||
final hasRider =
|
||||
riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0;
|
||||
final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
child: SizedBox(
|
||||
@@ -301,45 +396,21 @@ class StopDetailSheet extends StatelessWidget {
|
||||
// later, which is what Uber's own sheets do.
|
||||
child: AfterEntrance(
|
||||
placeholder: MapPlaceholder(radius: 14.r),
|
||||
builder: (context) => IgnorePointer(
|
||||
// Not interactive: a pannable map inside a draggable sheet fights
|
||||
// the sheet's own gestures, and this is not where routing happens.
|
||||
// [MilerMap] carries its own gesture set; the IgnorePointer above it
|
||||
// is what actually settles the argument.
|
||||
child: MilerMap(
|
||||
initialCenter: stopPos,
|
||||
initialZoom: 14.5,
|
||||
polylines: [
|
||||
if (riderPos != null)
|
||||
Polyline(
|
||||
points: [riderPos, stopPos],
|
||||
color: kind.accent,
|
||||
strokeWidth: 4,
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
],
|
||||
markers: [
|
||||
milerMarker(
|
||||
point: stopPos,
|
||||
width: MilerPin.size + 8,
|
||||
height: MilerPin.size + MilerPin.tail,
|
||||
// The stop's own accent, not one of nine stock hues — the same
|
||||
// colour as the chip on the card this sheet opened from.
|
||||
child: MilerPin(
|
||||
color: kind.accent,
|
||||
icon: kind.icon,
|
||||
emphasised: true,
|
||||
),
|
||||
),
|
||||
if (riderPos != null)
|
||||
milerMarker(
|
||||
point: riderPos,
|
||||
width: MilerRiderDot.size,
|
||||
height: MilerRiderDot.size,
|
||||
child: const MilerRiderDot(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The leg, drawn honestly — the real road from OSRM, both endpoints
|
||||
// pinned, camera fitted to the pair. This was a solid straight line
|
||||
// to a fixed-zoom frame: a rider 4 km out watched the stroke leave
|
||||
// the screen with no second point anywhere on it. See [MilerLegMap].
|
||||
builder: (context) => MilerLegMap(
|
||||
to: LatLng(lat, lng),
|
||||
from:
|
||||
(riderLat != null &&
|
||||
riderLng != null &&
|
||||
riderLat != 0 &&
|
||||
riderLng != 0)
|
||||
? LatLng(riderLat!, riderLng!)
|
||||
: null,
|
||||
accent: kind.accent,
|
||||
icon: kind.icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -351,31 +422,43 @@ class StopDetailSheet extends StatelessWidget {
|
||||
final travel = RouteMetricsHelper.travelTime(meters);
|
||||
if (distance == '—') return const SizedBox.shrink();
|
||||
|
||||
// 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(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.near_me_rounded,
|
||||
LucideIcons.navigation,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Text(
|
||||
'$distance away',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
Flexible(
|
||||
child: Text(
|
||||
'$distance away',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (travel > Duration.zero) ...[
|
||||
Text(
|
||||
' · ${RouteMetricsHelper.formatDuration(travel)} ride',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -387,7 +470,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -414,24 +497,57 @@ class StopDetailSheet extends StatelessWidget {
|
||||
child: Icon(icon, size: 17.sp, color: color),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
// ── The task and what it asks for, stacked ──
|
||||
//
|
||||
// These were side by side, with the requirement pushed hard against
|
||||
// the right edge — so "OTP + photo proof" was the one line on the
|
||||
// sheet cut off first at large text, and at any size it read as a
|
||||
// column heading rather than as a condition of the job above it.
|
||||
//
|
||||
// Under the title it is plainly a qualifier, it has the full width
|
||||
// to wrap into, and the row can no longer overflow.
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
detail,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
if (detail.isNotEmpty) ...[
|
||||
SizedBox(height: 3.h),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.squareCheckBig,
|
||||
size: 12.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
detail,
|
||||
maxLines: 2,
|
||||
// Secondary, not the leg accent: set in link blue it
|
||||
// read as something to tap, and it is a statement.
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -441,6 +557,15 @@ class StopDetailSheet extends StatelessWidget {
|
||||
|
||||
Widget _detailGrid() {
|
||||
final rows = <MapEntry<String, String>>[
|
||||
// Whose counter the bag came off — the fact the delivery card carries
|
||||
// ("PICKED UP FROM …") and this sheet used to drop. Only on the drop
|
||||
// leg: on a pickup the header IS the source context. One reader,
|
||||
// [stopSourceName], as the project rule demands.
|
||||
MapEntry('From', _isDropLeg ? stopSourceName(stop) : ''),
|
||||
// The bag is what a rider matches against a shelf, so it outranks the
|
||||
// reference. Stored pairing first ([bag], written at the counter), the
|
||||
// kitchen-printed label as fallback, absent on a line with no load.
|
||||
MapEntry('Bag', bag.isNotEmpty ? bag : _s(['baglabel', 'BagLabel'])),
|
||||
MapEntry('Order', _s(['orderid', 'OrderId'])),
|
||||
MapEntry('Product', _s(['description', 'productname', 'product'])),
|
||||
MapEntry('Weight', () {
|
||||
@@ -457,42 +582,124 @@ class StopDetailSheet extends StatelessWidget {
|
||||
|
||||
if (rows.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Divider(height: 20.h, color: ColorConstants.borderSubtle),
|
||||
for (final r in rows)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 8.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110.w,
|
||||
child: Text(
|
||||
r.key,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
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])),
|
||||
],
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
r.value,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// ── 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.
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One label-over-value fact.
|
||||
///
|
||||
/// ── Why this stopped being a table ──
|
||||
///
|
||||
/// It was `label ── value` rows on the sheet's own grey, with a fixed 110pt
|
||||
/// label column. Two problems, both of them about reading it at a gate. The
|
||||
/// values — the half a rider actually needs — sat in a ragged right column
|
||||
/// and had to be found one at a time; and a long one ("SNS Colony,
|
||||
/// Peelamedu") wrapped under a wide empty label, so the block grew a hole in
|
||||
/// the middle of it.
|
||||
///
|
||||
/// Stacked, the value is the big line and the label is the small one above
|
||||
/// 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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
fact.key.toUpperCase(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.9,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 3.h),
|
||||
// ── A reference is one token, so it renders as one ──
|
||||
//
|
||||
// `DM-BK-BDD9EF53-35734` in a half-width tile wrapped at a hyphen —
|
||||
// "DM-BK-" over "BDD9EF53-35734" — which is exactly how a reference
|
||||
// gets misread down a phone to the office. Anything with no spaces
|
||||
// in it is a token, not prose: it shrinks to fit its line instead
|
||||
// of breaking. Real sentences keep the two-line wrap.
|
||||
if (!fact.value.contains(' ') && fact.value.length > 12)
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
fact.value,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
fact.value,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -510,7 +717,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sticky_note_2_rounded,
|
||||
LucideIcons.stickyNote,
|
||||
size: 17.sp,
|
||||
color: ColorConstants.warning,
|
||||
),
|
||||
@@ -521,7 +728,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onErrorContainer,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -545,7 +752,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
Expanded(
|
||||
child: MilerButton(
|
||||
label: 'Call',
|
||||
icon: Icons.call_rounded,
|
||||
icon: LucideIcons.phone,
|
||||
variant: MilerButtonVariant.outlined,
|
||||
color: ColorConstants.acceptGreen,
|
||||
onPressed: () => _dial(phone),
|
||||
@@ -555,11 +762,26 @@ class StopDetailSheet extends StatelessWidget {
|
||||
],
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: MilerButton(
|
||||
label: 'Navigate',
|
||||
icon: Icons.navigation_rounded,
|
||||
color: ColorConstants.primary,
|
||||
onPressed: hasLocation ? () => _navigate(lat, lng) : null,
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// A TRIP — one slot's worth of work, as a single unit.
|
||||
@@ -24,6 +27,147 @@ import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
|
||||
/// and safe to call from `build`.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHICH TRIP A STOP IS ON
|
||||
///
|
||||
/// A rider's day is three trips, and which one an order lands on is decided by
|
||||
/// **when the work is due**, not by how many buckets happen to have filled up:
|
||||
///
|
||||
/// morning → Trip 1
|
||||
/// afternoon → Trip 2
|
||||
/// evening → Trip 3
|
||||
///
|
||||
/// ── What this replaces ──
|
||||
///
|
||||
/// Stops were bucketed into rolling three-hour windows aligned to the clock —
|
||||
/// 09:00–12:00, 12:00–15:00, and so on — which produces up to eight buckets a
|
||||
/// day, folded down to three at the end. Two consequences, both wrong on a
|
||||
/// real morning: an 08:30 stop and an 11:00 stop are both *the morning run*
|
||||
/// and landed on different trips, and which trip a stop appeared on depended
|
||||
/// on what else was in the day. A rider with only afternoon work saw it as
|
||||
/// "Trip 1", so his Trip 2 and the hub's Trip 2 were different things.
|
||||
///
|
||||
/// The boundaries are fixed here rather than derived, because they are a
|
||||
/// business fact about the shift and not something to infer from the data.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
enum DayPart {
|
||||
morning('Morning'),
|
||||
afternoon('Afternoon'),
|
||||
evening('Evening');
|
||||
|
||||
const DayPart(this.label);
|
||||
|
||||
/// What this part of the day is called, on a tab and in a sentence.
|
||||
final String label;
|
||||
|
||||
/// Afternoon begins at noon.
|
||||
static const int afternoonFromHour = 12;
|
||||
|
||||
/// Evening begins at 5pm.
|
||||
static const int eveningFromHour = 17;
|
||||
|
||||
/// The day part [at] falls in.
|
||||
static DayPart of(DateTime at) {
|
||||
if (at.hour < afternoonFromHour) return DayPart.morning;
|
||||
if (at.hour < eveningFromHour) return DayPart.afternoon;
|
||||
return DayPart.evening;
|
||||
}
|
||||
|
||||
/// The window this part covers on [day].
|
||||
static (DateTime, DateTime) windowOn(DateTime day, DayPart part) {
|
||||
final midnight = DateTime(day.year, day.month, day.day);
|
||||
return switch (part) {
|
||||
DayPart.morning => (
|
||||
midnight,
|
||||
midnight.add(const Duration(hours: afternoonFromHour)),
|
||||
),
|
||||
DayPart.afternoon => (
|
||||
midnight.add(const Duration(hours: afternoonFromHour)),
|
||||
midnight.add(const Duration(hours: eveningFromHour)),
|
||||
),
|
||||
DayPart.evening => (
|
||||
midnight.add(const Duration(hours: eveningFromHour)),
|
||||
midnight.add(const Duration(days: 1)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// True when [from]–[to] is exactly one day part's window, which is how
|
||||
/// [Trip.slotLabel] knows to say "Morning" instead of a clock range.
|
||||
static bool spans(DateTime from, DateTime to) {
|
||||
final (start, end) = windowOn(from, of(from));
|
||||
return from.isAtSameMomentAs(start) && to.isAtSameMomentAs(end);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three slots of a day, whether or not the rider has work in each.
|
||||
///
|
||||
/// 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.
|
||||
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.
|
||||
///
|
||||
/// Dated trips claim their own part. Anything undated — a tenant that sends
|
||||
/// no times, demo data, a trip whose stops carry only addresses — falls back
|
||||
/// to the first free slot, in order, because a trip that cannot be placed
|
||||
/// still has to be reachable. Guessing a part from the wall clock instead
|
||||
/// would quietly move such a trip from Trip 1 to Trip 2 at noon.
|
||||
List<Trip?> get slots {
|
||||
final out = List<Trip?>.filled(DayPart.values.length, null, growable: true);
|
||||
|
||||
// ── Day parts place the trips only when they can place all of them ──
|
||||
//
|
||||
// A trip claims the slot its part names — that is what makes an
|
||||
// afternoon-only day read as Trip 2 rather than Trip 1. It works because
|
||||
// the common day is one run per part.
|
||||
//
|
||||
// When two trips want the *same* part it stops working, and the first
|
||||
// attempt at patching it made things worse: the second trip took the first
|
||||
// free slot anywhere, so on an evening with two evening runs the tabs came
|
||||
// out `[2, 0]` — trip one under Trip 3, trip two under Trip 1, inverted,
|
||||
// and only between 5pm and midnight. A layout that depends on the hour the
|
||||
// rider opened the screen is worse than one that ignores day parts.
|
||||
//
|
||||
// So it is all or nothing. Every trip has its own part, or the run is laid
|
||||
// out in the order it arrived — which is already sorted, and which is the
|
||||
// honest answer when the data does not fit the model.
|
||||
final parts = <int>{};
|
||||
var distinct = true;
|
||||
for (final t in this) {
|
||||
final p = t.dayPart?.index;
|
||||
if (p == null || !parts.add(p)) {
|
||||
distinct = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (distinct) {
|
||||
for (final t in this) {
|
||||
out[t.dayPart!.index] = t;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
if (i < out.length) {
|
||||
out[i] = this[i];
|
||||
} else {
|
||||
out.add(this[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The trip in slot [index] (0 = morning), or null when nothing is assigned
|
||||
/// to that part of the day.
|
||||
Trip? tripAt(int index) {
|
||||
final s = slots;
|
||||
return index >= 0 && index < s.length ? s[index] : null;
|
||||
}
|
||||
}
|
||||
|
||||
class Trip {
|
||||
/// Stable identity for this trip (backend slot/trip id, or a derived key).
|
||||
final String id;
|
||||
@@ -82,6 +226,10 @@ class Trip {
|
||||
final s = slotStart;
|
||||
final e = slotEnd;
|
||||
if (s == null || e == null) return "Today's route";
|
||||
// A day part is named, not spelled out as a clock range: "Morning" is what
|
||||
// the rider and the hub both call it, and "5:00 AM – 12:00 PM" says the
|
||||
// same thing in seven characters more and one reading step.
|
||||
if (DayPart.spans(s, e)) return DayPart.of(s).label;
|
||||
return '${RouteMetricsHelper.formatClock(s)} – '
|
||||
'${RouteMetricsHelper.formatClock(e)}';
|
||||
}
|
||||
@@ -167,8 +315,43 @@ class Trip {
|
||||
rejectedIds: rejectedIds,
|
||||
).any((s) => s.needsDecision);
|
||||
|
||||
/// Short tab label — `Trip 1`.
|
||||
static String tabLabel(int index) => 'Trip ${index + 1}';
|
||||
/// Short tab label for a **slot** — `Trip 1`.
|
||||
///
|
||||
/// The slot is the day part, not a running count: slot 0 is the morning
|
||||
/// whether or not the rider has morning work. See [DayPart].
|
||||
static String tabLabel(int index) =>
|
||||
'Trip ${index + 1}'; // slot index is the day part's index
|
||||
|
||||
/// Which part of the day this trip belongs to — **null when nothing on it is
|
||||
/// dated**.
|
||||
///
|
||||
/// Taken from the slot the grouping put it in. A trip the backend named with
|
||||
/// its own `tripid` still gets one, from whatever window its stops fall in.
|
||||
///
|
||||
/// Null matters: a trip with no times at all cannot be placed by day part,
|
||||
/// and guessing one from the wall clock would move it between tabs as the
|
||||
/// afternoon wore on. Those keep their position instead — see
|
||||
/// [TripSlots.slots].
|
||||
DayPart? get dayPart {
|
||||
final at = slotStart ?? _earliestDue;
|
||||
return at == null ? null : DayPart.of(at);
|
||||
}
|
||||
|
||||
/// `1`, `2` or `3` — the number the rider and the hub both use. Zero when
|
||||
/// this trip has no time to place it by.
|
||||
int get tripNumber => (dayPart?.index ?? -1) + 1;
|
||||
|
||||
DateTime? get _earliestDue {
|
||||
DateTime? best;
|
||||
for (final s in stops) {
|
||||
final due = _parseTimestamp(
|
||||
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||||
);
|
||||
if (due == null) continue;
|
||||
if (best == null || due.isBefore(best)) best = due;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Address components every stop on this trip shares — city, state,
|
||||
/// pincode and so on. Cached per build by the caller.
|
||||
@@ -211,13 +394,27 @@ class Trip {
|
||||
|
||||
/// Estimated ride time from the previous point to stop [index]. For index 0
|
||||
/// that is the hub → stop 1 leg.
|
||||
Duration travelTimeToStop(int index, {double? hubLat, double? hubLng}) {
|
||||
Duration travelTimeToStop(
|
||||
int index, {
|
||||
double? hubLat,
|
||||
double? hubLng,
|
||||
// ── The rail's legs run door to door, not counter to counter ──
|
||||
//
|
||||
// On the Deliveries rail every stop is the delivery leg, and this walked
|
||||
// the route over the PICKUP pairs — so the remaining-time estimate was a
|
||||
// tour of the kitchens the rider had already left. The flag mirrors
|
||||
// `RouteMetricsHelper.metersToStop(toDrop:)`: the previous stop's exit
|
||||
// point and this stop's target are both the drop when the leg is a
|
||||
// delivery, with the pickup pair as the payload fallback. Default false —
|
||||
// Home's pickup-leg arithmetic is untouched.
|
||||
bool deliveryLeg = false,
|
||||
}) {
|
||||
if (index < 0 || index >= stops.length) return Duration.zero;
|
||||
|
||||
final ({double lat, double lng})? from = index == 0
|
||||
? _origin(hubLat, hubLng)
|
||||
: _coordsOf(stops[index - 1]);
|
||||
final to = _coordsOf(stops[index]);
|
||||
: _coordsOf(stops[index - 1], toDrop: deliveryLeg);
|
||||
final to = _coordsOf(stops[index], toDrop: deliveryLeg);
|
||||
if (from == null || to == null) return Duration.zero;
|
||||
|
||||
final meters = RouteMetricsHelper.distanceMeters(
|
||||
@@ -356,7 +553,10 @@ class Trip {
|
||||
|
||||
static List<Trip> groupIntoTrips(
|
||||
List<Map<String, dynamic>> stops, {
|
||||
int windowHours = 3,
|
||||
|
||||
/// No longer used for bucketing — stops are grouped by [DayPart]. Kept so
|
||||
/// existing call sites compile; passing it changes nothing.
|
||||
@Deprecated('Trips are bucketed by DayPart') int windowHours = 3,
|
||||
double? hubLat,
|
||||
double? hubLng,
|
||||
DateTime? now,
|
||||
@@ -403,10 +603,14 @@ class Trip {
|
||||
stop['eta'],
|
||||
);
|
||||
if (due != null) {
|
||||
final blockHour = (due.hour ~/ windowHours) * windowHours;
|
||||
from = DateTime(due.year, due.month, due.day, blockHour);
|
||||
to = from.add(Duration(hours: windowHours));
|
||||
key = 'win:${from.toIso8601String()}';
|
||||
// Morning, afternoon or evening — see [DayPart]. Not a rolling
|
||||
// window: two stops due an hour apart are on the same run, and the
|
||||
// trip a stop belongs to must not depend on what else is in the day.
|
||||
final part = DayPart.of(due);
|
||||
final (partFrom, partTo) = DayPart.windowOn(due, part);
|
||||
from = partFrom;
|
||||
to = partTo;
|
||||
key = 'part:${due.year}-${due.month}-${due.day}:${part.name}';
|
||||
} else {
|
||||
from = null;
|
||||
to = null;
|
||||
@@ -436,6 +640,11 @@ class Trip {
|
||||
if (as == null && bs == null) return 0;
|
||||
if (as == null) return 1; // undated trips last
|
||||
if (bs == null) return -1;
|
||||
// Day part first, so Trip 1 is always the morning even on a day that
|
||||
// starts at two in the afternoon. Within a part, earliest first.
|
||||
final pa = a.dayPart?.index ?? DayPart.values.length;
|
||||
final pb = b.dayPart?.index ?? DayPart.values.length;
|
||||
if (pa != pb) return pa.compareTo(pb);
|
||||
return as.compareTo(bs);
|
||||
});
|
||||
|
||||
@@ -489,37 +698,39 @@ class Trip {
|
||||
/// Puts stops in the admin's intended order.
|
||||
///
|
||||
/// `step` is authoritative — it IS the admin's solved sequence, and the app
|
||||
/// must never second-guess it. Only when `step` is absent do we fall back to
|
||||
/// the booked time, then to the order the backend sent. Sorting by distance
|
||||
/// would be re-optimising the route, which the rider is not allowed to do.
|
||||
/// must never second-guess it. Only when no stop carries one do we fall back
|
||||
/// to the booked time, then to the order the backend sent. **Distance is not
|
||||
/// one of the options here**: sorting by it would be re-optimising a route
|
||||
/// the hub has planned, which the rider is not allowed to do.
|
||||
///
|
||||
/// The rule itself lives in [RouteOrder] — one implementation, shared with
|
||||
/// the Deliveries tab, which used to keep its own and disagreed. This is the
|
||||
/// pickup leg's door onto it.
|
||||
static List<Map<String, dynamic>> sortStops(
|
||||
List<Map<String, dynamic>> stops,
|
||||
) {
|
||||
final indexed = <(int, Map<String, dynamic>)>[
|
||||
for (var i = 0; i < stops.length; i++) (i, stops[i]),
|
||||
];
|
||||
) => orderStops(stops).$1;
|
||||
|
||||
indexed.sort((a, b) {
|
||||
final stepA = _toInt(a.$2['step'] ?? a.$2['Step']);
|
||||
final stepB = _toInt(b.$2['step'] ?? b.$2['Step']);
|
||||
if (stepA > 0 && stepB > 0 && stepA != stepB) return stepA - stepB;
|
||||
if (stepA > 0 && stepB <= 0) return -1;
|
||||
if (stepB > 0 && stepA <= 0) return 1;
|
||||
|
||||
final dueA = _parseTimestamp(a.$2['expected_pickup_time']);
|
||||
final dueB = _parseTimestamp(b.$2['expected_pickup_time']);
|
||||
if (dueA != null && dueB != null && dueA != dueB) {
|
||||
return dueA.compareTo(dueB);
|
||||
}
|
||||
return a.$1 - b.$1; // stable: keep backend order
|
||||
});
|
||||
|
||||
return [for (final e in indexed) e.$2];
|
||||
}
|
||||
/// [sortStops], plus which rule produced the order.
|
||||
static (List<Map<String, dynamic>>, RouteOrderSource) orderStops(
|
||||
List<Map<String, dynamic>> stops,
|
||||
) => RouteOrder.sort(
|
||||
stops,
|
||||
bookedTimeOf: (s) => _parseTimestamp(
|
||||
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||||
),
|
||||
);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
static ({double lat, double lng})? _coordsOf(Map<String, dynamic> stop) {
|
||||
static ({double lat, double lng})? _coordsOf(
|
||||
Map<String, dynamic> stop, {
|
||||
bool toDrop = false,
|
||||
}) {
|
||||
if (toDrop) {
|
||||
final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']);
|
||||
final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']);
|
||||
if (dLat != 0 && dLng != 0) return (lat: dLat, lng: dLng);
|
||||
}
|
||||
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
|
||||
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
|
||||
if (lat == 0 || lng == 0) return null;
|
||||
@@ -540,12 +751,6 @@ class Trip {
|
||||
return DateTime.tryParse(v.toString().trim());
|
||||
}
|
||||
|
||||
static int _toInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse(v.toString().trim()) ?? 0;
|
||||
}
|
||||
|
||||
static double _toDouble(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toDouble();
|
||||
@@ -634,6 +839,14 @@ enum StopState {
|
||||
/// Accepted, not yet started. Lives on the Bookings tab.
|
||||
accepted,
|
||||
|
||||
/// Loaded from its source and physically in the rider's hands.
|
||||
///
|
||||
/// Service routes only. The parcel ladder has no equivalent — for a parcel,
|
||||
/// collecting it from the customer *is* the job — so this state exists only
|
||||
/// where accepting and carrying are two different days' work apart. See
|
||||
/// [ServiceProfile.handoffAt] and the collected store.
|
||||
collected,
|
||||
|
||||
/// The rider is physically on this stop right now.
|
||||
active,
|
||||
|
||||
@@ -660,17 +873,46 @@ extension StopStateX on StopState {
|
||||
/// Committed to: he has taken it and owes the customer a visit.
|
||||
bool get isCommitted =>
|
||||
this == StopState.accepted ||
|
||||
this == StopState.collected ||
|
||||
this == StopState.active ||
|
||||
this == StopState.skipped;
|
||||
|
||||
String get label => switch (this) {
|
||||
StopState.pending => 'Awaiting your decision',
|
||||
StopState.accepted => 'Accepted',
|
||||
StopState.active => 'In progress',
|
||||
StopState.done => 'Completed',
|
||||
StopState.skipped => 'Skipped',
|
||||
StopState.rejected => 'Rejected',
|
||||
};
|
||||
/// In the rider's hands right now.
|
||||
bool get isCollected => this == StopState.collected;
|
||||
|
||||
/// What this state is called on a card.
|
||||
///
|
||||
/// ── Two vocabularies, one state machine ──
|
||||
///
|
||||
/// The Doormile wording is written for a rider who is being *asked* something:
|
||||
/// "Awaiting your decision" is true because the card under it carries Accept
|
||||
/// and Reject. A service rider is asked nothing — the whole assignment came as
|
||||
/// one commitment — so the same state means "the hub has given you this, it is
|
||||
/// waiting for you to go and collect it", and calling that a decision invites
|
||||
/// him to look for a button that is not there.
|
||||
///
|
||||
/// `Delivered` rather than `Completed` for the same reason: on a meal run the
|
||||
/// last thing that happens at a door is a hand-over, and naming it is worth
|
||||
/// more than a word that covers every stop type in the app.
|
||||
String get label => ServiceProfile.active.acceptsPerStop
|
||||
? switch (this) {
|
||||
StopState.pending => 'Awaiting your decision',
|
||||
StopState.accepted => 'Accepted',
|
||||
StopState.collected => 'Collected',
|
||||
StopState.active => 'In progress',
|
||||
StopState.done => 'Completed',
|
||||
StopState.skipped => 'Skipped',
|
||||
StopState.rejected => 'Rejected',
|
||||
}
|
||||
: switch (this) {
|
||||
StopState.pending => 'Pending',
|
||||
StopState.accepted => 'Accepted',
|
||||
StopState.collected => 'Collected',
|
||||
StopState.active => 'Out for delivery',
|
||||
StopState.done => 'Delivered',
|
||||
StopState.skipped => 'Skipped',
|
||||
StopState.rejected => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
/// Derives a stop's state from its backend status plus the local
|
||||
@@ -684,6 +926,10 @@ StopState stopStateOf(
|
||||
Map<String, dynamic> stop, {
|
||||
required Set<String> acceptedIds,
|
||||
required Set<String> rejectedIds,
|
||||
|
||||
/// Orders the rider is carrying. Optional so every existing parcel call site
|
||||
/// is unchanged — a Doormile route never populates it.
|
||||
Set<String> collectedIds = const <String>{},
|
||||
}) {
|
||||
final id = (stop['orderid'] ?? '').toString();
|
||||
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
|
||||
@@ -704,14 +950,29 @@ StopState stopStateOf(
|
||||
if (raw == 'active' || raw == 'arrived') return StopState.active;
|
||||
if (raw == 'skipped') return StopState.skipped;
|
||||
|
||||
// 3. Local decisions next, and they beat the server's accept/reject.
|
||||
// 2b. Released for delivery — which `pickup-complete` does by itself on
|
||||
// hyperlocal work, in the same call that records the collection. It says
|
||||
// the consignment may be delivered, not that the rider has set off, so it
|
||||
// resolves to *carrying* and never to [StopState.active]. Asked ahead of
|
||||
// the local sets because it must also hold after a reinstall, when the
|
||||
// collected set is empty and this row would otherwise fall through to
|
||||
// `pending` — putting a bag already in his box back on Home as work to
|
||||
// accept. See `MilkRun.stageOf`.
|
||||
if (raw == 'outfordelivery') return StopState.collected;
|
||||
|
||||
// 3. Carrying it beats every remaining local view. He has the food; no
|
||||
// later tap on this screen can make that untrue, and a refetch that still
|
||||
// reports `accepted` must not put the card back on Home.
|
||||
if (collectedIds.contains(id)) return StopState.collected;
|
||||
|
||||
// 4. Local decisions next, and they beat the server's accept/reject.
|
||||
// They are strictly newer: the rider just tapped, and the server view is
|
||||
// at best one poll behind. Without this, un-rejecting a stop would be
|
||||
// undone by the next refetch still reporting `rejected`.
|
||||
if (rejectedIds.contains(id)) return StopState.rejected;
|
||||
if (acceptedIds.contains(id)) return StopState.accepted;
|
||||
|
||||
// 4. Finally the server's own view.
|
||||
// 5. Finally the server's own view.
|
||||
if (raw == 'rejected') return StopState.rejected;
|
||||
if (raw == 'accepted') return StopState.accepted;
|
||||
return StopState.pending;
|
||||
@@ -732,6 +993,41 @@ enum StopProgress {
|
||||
skipped,
|
||||
}
|
||||
|
||||
/// How one stop's lifecycle rung reads on the route rail.
|
||||
///
|
||||
/// ── "Physically on" is a question about BOTH legs ──
|
||||
///
|
||||
/// This lived inline in `_MyPickupsState._tripProgress` and tested
|
||||
/// `isActive || arrived` — and both of those are **pickup-leg** rungs. It is
|
||||
/// read on the Deliveries tab, where every stop is past `pickup-complete` by
|
||||
/// definition, so on a milk run the rung the rider is actually standing on is
|
||||
/// [StopStatus.deliveryArrived], which matched neither. The customer's door he
|
||||
/// was at *that moment* drew grey — indistinguishable from the eleven he had
|
||||
/// not ridden to yet — and the scooter fell through to the caller's "promote
|
||||
/// the first pending one" fallback, parking it somewhere up the route behind
|
||||
/// him.
|
||||
///
|
||||
/// The rail's whole job is *where am I*. Answering it from one leg's
|
||||
/// vocabulary, on the tab that only ever shows the other leg, is why the answer
|
||||
/// was wrong all round.
|
||||
///
|
||||
/// [StopStatusX.isWorkComplete] decides `done`, so the answer is line-aware:
|
||||
/// `picked` ends a logistics stop and is the middle of the morning on a round.
|
||||
///
|
||||
/// Pure, and out here rather than on the State, because `_MyPickupsState`
|
||||
/// cannot be pumped — Get, Geolocator and a 3s poll — so a mapping left inside
|
||||
/// it can only ever be verified by reading it. See `trip_progress_test.dart`.
|
||||
StopProgress stopProgressFor(StopStatus status) {
|
||||
if (status.isWorkComplete) return StopProgress.done;
|
||||
if (status.isSkipped) return StopProgress.skipped;
|
||||
if (status.isActive ||
|
||||
status == StopStatus.arrived ||
|
||||
status == StopStatus.deliveryArrived) {
|
||||
return StopProgress.current;
|
||||
}
|
||||
return StopProgress.pending;
|
||||
}
|
||||
|
||||
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
|
||||
String formatTripDuration(Duration d) {
|
||||
if (d.inMinutes < 1) return '—';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/views/Dashboard/home/route_brief.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
@@ -89,121 +91,298 @@ class TripBriefStrip extends StatefulWidget {
|
||||
State<TripBriefStrip> createState() => _TripBriefStripState();
|
||||
}
|
||||
|
||||
/// ── The figures are reference, so they are folded ──
|
||||
///
|
||||
/// Duration, distance, parcels and payment were drawn permanently, four
|
||||
/// captioned columns above `TODAY'S RUN`. On a 390×844 phone that block plus
|
||||
/// the trip tabs plus the app bar reached the fold before the first kitchen,
|
||||
/// so the screen opened on a dashboard about the run rather than on the run.
|
||||
///
|
||||
/// None of the four is something a rider acts on mid-shift. They are what he
|
||||
/// checks once when he picks the trip up and again when he reconciles — the
|
||||
/// definition of reference material, and this file's own doc already said as
|
||||
/// much about the collapsed face.
|
||||
///
|
||||
/// What stays out is the progress face: stops left, and cash in hand. Those
|
||||
/// 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> {
|
||||
/// Starts closed. The brief is a once-a-shift read, and defaulting it open
|
||||
/// would give back exactly the height this widget exists to reclaim.
|
||||
bool _open = false;
|
||||
|
||||
Trip get _trip => widget.trip;
|
||||
RouteBrief get _brief => widget.brief;
|
||||
int get percent => widget.percent;
|
||||
VoidCallback? get onViewRoute => widget.onViewRoute;
|
||||
|
||||
/// Stops the rider has finished, derived from the percentage the page already
|
||||
/// computed rather than recounting the sets here.
|
||||
int get _done => (_trip.stopCount * widget.percent / 100).round();
|
||||
int get _done => (_trip.stopCount * percent / 100).round();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
// 12, which is the page gutter — the same value `_footerBar` uses and the
|
||||
// one the header this replaced used. It reads as 28 when [TripCard] is
|
||||
// composed whole (its own 16 plus this), and as 12 on Home, which calls
|
||||
// the three sections directly so that the trip tabs can be a pinned
|
||||
// sliver between them. The stop rows deliberately sit 4 further in; see
|
||||
// the gutter group in `card_density_test.dart`.
|
||||
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 4.h),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
// One surface, one shadow. The stack this replaced nested a shadowed
|
||||
// card inside padding inside three more shadowed cards — four
|
||||
// elevations describing one object.
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 5),
|
||||
// ── The brief is a working region, so it stands on one ──
|
||||
//
|
||||
// This was a bare `Padding` on Home's ground, and Home's ground is the
|
||||
// canvas: grey figures on grey, with nothing behind them. [MilerPanel] is
|
||||
// layer 1 of the surface ladder — white, no border — and the canvas
|
||||
// showing around it is what turns a column of text into a region.
|
||||
//
|
||||
// The panel also settles a gutter argument. The old note here claimed 12
|
||||
// put this head "where `TODAY'S RUN` and the timeline's own gutter already
|
||||
// sit"; measured, they were four points apart. Both sections now take
|
||||
// their edge from `MilerSurface.panelGutter` + `panelPad`, so they cannot
|
||||
// disagree — and `home_gutter_test` fails if they ever do.
|
||||
return MilerPanel(
|
||||
// ── Not the bordered card that was removed from here ──
|
||||
//
|
||||
// That one was a `pureSurface` fill inside a `borderSubtle` outline on a
|
||||
// near-white page: #FFFFFF on #F8FAFC, a four-percent step, so the
|
||||
// border did all of the separating and the fill did none. Removing it
|
||||
// was right for that ground.
|
||||
//
|
||||
// The ground changed. The canvas moved to #DEE3EA and white now stands
|
||||
// off it at 1.290 : 1, which is a surface rather than a hairline — so
|
||||
// this returns as a *fill with no border*, which is the thing the old
|
||||
// box was only pretending to be.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
expanded: _open,
|
||||
label: _open ? 'Hide trip figures' : 'Show trip figures',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
child: _head(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Grows and shrinks in place rather than the body appearing in one
|
||||
// frame, so the stop list below is pushed down visibly and the rider
|
||||
// can see where the extra content came from.
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [_face(), if (_open) _body()],
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: AnimatedSize(
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _open
|
||||
? _body()
|
||||
: const SizedBox(width: double.infinity, height: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── The collapsed face ───────────────────────────────────────────────────
|
||||
//
|
||||
// Ring, the two live figures, the cash chip, the chevron. Nothing else ever
|
||||
// appears here.
|
||||
Widget _face() {
|
||||
final radius = BorderRadius.circular(DesignConstants.radiusXl);
|
||||
final cash = _trip.cashToCollect;
|
||||
// ── The head ─────────────────────────────────────────────────────────────
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
expanded: _open,
|
||||
label: _open ? 'Hide trip details' : 'Show trip details',
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
borderRadius: radius,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(14.w, 12.h, 10.w, 12.h),
|
||||
child: Row(
|
||||
/// ── The progress rule is gone, and so is the disclosure ──
|
||||
///
|
||||
/// The 3pt `LinearProgressIndicator` under this line was a **thin
|
||||
/// indeterminate-looking bar directly below the app bar**, which is the exact
|
||||
/// position and the exact shape every app on the phone uses to say *this
|
||||
/// screen is still loading*. It was reporting completion, and it was read as
|
||||
/// a spinner. A signal that is misread by everyone who sees it is worth less
|
||||
/// than the height it costs, and the sentence above it already gave the same
|
||||
/// figure in the unit the rider works in.
|
||||
///
|
||||
/// The chevron went with it. The four journey figures behind it were a
|
||||
/// once-a-shift read that nobody was opening, so the information was shipped
|
||||
/// and unreachable. They are on the face now — one card, always open, which
|
||||
/// is the state the rider only ever saw by accident before.
|
||||
Widget _head() {
|
||||
final cash = _trip.cashToCollect;
|
||||
final total = _trip.stopCount;
|
||||
final left = total - _done;
|
||||
final complete = total > 0 && left <= 0;
|
||||
final pct = total > 0 ? (_done / total).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
// ── A figure, not a sentence ──
|
||||
//
|
||||
// "15 stops left" set in body type was a headline wearing a paragraph's
|
||||
// clothes: the one number a rider steers his shift by, at the same weight
|
||||
// as everything around it. Every dashboard he already reads — the duty
|
||||
// apps, the pay apps — leads with the figure big and captions it small,
|
||||
// because a glance lands on a numeral long before it lands on a phrase.
|
||||
// So the count is the headline and the words are its caption.
|
||||
//
|
||||
// ── And the bar is back, on different terms ──
|
||||
//
|
||||
// A progress bar was removed from this widget once, correctly: 3pt thin,
|
||||
// drawn directly under the app bar, it sat in the exact position and
|
||||
// shape every app uses for "this screen is loading". What returns is not
|
||||
// that bar. It lives inside the card, under its own numeral, above its
|
||||
// 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.
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
// 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
|
||||
// keeps going. Unclamped, "3 stops left" at 2.0× on a 320pt phone
|
||||
// overflowed its own row by 31px.
|
||||
child: MediaQuery.withClampedTextScaling(
|
||||
maxScaleFactor: 1.3,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_MiniRing(percent: widget.percent),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$_done of ${_trip.stopCount} '
|
||||
'${_trip.stopCount == 1 ? 'stop' : 'stops'} done',
|
||||
if (complete) ...[
|
||||
Icon(
|
||||
LucideIcons.circleCheck,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'All $total done',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
color: ColorConstants.acceptGreen,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
] 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: 15.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.slateText,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
_timeLeftLine(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (cash > 0) ...[SizedBox(width: 8.w), _cashChip(cash)],
|
||||
SizedBox(width: 2.w),
|
||||
],
|
||||
const Spacer(),
|
||||
if (cash > 0) ...[_cashChip(cash), SizedBox(width: 8.w)],
|
||||
AnimatedRotation(
|
||||
turns: _open ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down_rounded,
|
||||
size: 24.sp,
|
||||
LucideIcons.chevronDown,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.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
|
||||
// track, which with the numeral above it cannot be misread.
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
child: Container(
|
||||
height: 6.h,
|
||||
width: double.infinity,
|
||||
color: MilerSurface.canvas,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedFractionallySizedBox(
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
widthFactor: pct,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: complete
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.primary,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 9.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,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The collapsed panel's journey, in one quiet line: `≈4h · 38.9 km`.
|
||||
///
|
||||
/// Only the figures that exist — a route with no coordinates prints no
|
||||
/// kilometres rather than a zero — and never a colour: this is context, and
|
||||
/// the headline beside it is the thing being contextualised.
|
||||
Widget _journeyGlance() {
|
||||
final duration = roundTripDuration(_trip.totalDuration);
|
||||
final parts = <String>[
|
||||
if (duration > Duration.zero) '\u2248${formatTripDuration(duration)}',
|
||||
if (_trip.routeMeters > 0)
|
||||
RouteMetricsHelper.formatDistance(_trip.routeMeters),
|
||||
];
|
||||
if (parts.isEmpty) return const SizedBox.shrink();
|
||||
// Scale-down, never ellipsis: this line is nothing but figures, and
|
||||
// `10.3 …` is the unit cut off the number it qualifies — seen the first
|
||||
// time it shared a row with the headline, both flexible.
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
parts.join(' \u00b7 '),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -221,6 +400,13 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// The hub has not set a window. Say nothing about time rather than
|
||||
// inventing a state — see the note on [ShiftBanner] for why an absent
|
||||
// shift is not the rider's problem to solve.
|
||||
//
|
||||
// And when the trip has no slot either, say nothing AT ALL:
|
||||
// `slotLabel`'s fallback is the words "Today's route", which set at
|
||||
// clock position read as a fact the rider should be able to act on. A
|
||||
// headline row carries a live figure or it carries silence — a label
|
||||
// pretending to be data is worse than the gap it fills.
|
||||
if (_trip.slotStart == null) return const SizedBox.shrink();
|
||||
text = _trip.slotLabel;
|
||||
color = ColorConstants.secondaryText;
|
||||
} else if (left == Duration.zero) {
|
||||
@@ -232,7 +418,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
text = '${formatDuration(left)} left · running tight';
|
||||
color = ColorConstants.warning;
|
||||
} else {
|
||||
text = '${formatDuration(left)} left in shift';
|
||||
text = '${formatDuration(left)} left';
|
||||
color = ColorConstants.secondaryText;
|
||||
}
|
||||
|
||||
@@ -242,29 +428,30 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// ── The cash is a figure, not a chip ──
|
||||
///
|
||||
/// It was a tinted pill, and a pill has vertical padding — which made the one
|
||||
/// element on the line that could **change the height of the whole strip**
|
||||
/// depend on whether the trip happened to carry money. Green on the page
|
||||
/// carries the meaning perfectly well without a capsule behind it.
|
||||
Widget _cashChip(double cash) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.acceptGreen.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Text(
|
||||
'₹${_money(cash)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.moneyGreen,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
return Text(
|
||||
'\u20b9${_money(cash)}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.moneyGreen,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -300,12 +487,13 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
final duration = roundTripDuration(_trip.totalDuration);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 14.h),
|
||||
// Horizontally flush with [_head], for the same reason.
|
||||
padding: EdgeInsets.only(bottom: 14.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Divider(height: 1, color: ColorConstants.borderSubtle),
|
||||
SizedBox(height: 10.h),
|
||||
SizedBox(height: 14.h),
|
||||
|
||||
// ── Band 1: the shift window ──
|
||||
//
|
||||
@@ -322,7 +510,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// rather than as a range.
|
||||
if (_brief.shiftSet) ...[
|
||||
_windowRow(
|
||||
icon: Icons.event_available_rounded,
|
||||
icon: LucideIcons.calendarCheck,
|
||||
label: 'Shift',
|
||||
value: _brief.shiftWindowLabel,
|
||||
tint: ColorConstants.slateText,
|
||||
@@ -342,21 +530,24 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: _figure(
|
||||
icon: Icons.schedule_rounded,
|
||||
icon: LucideIcons.clock,
|
||||
value: '≈${formatTripDuration(duration)}',
|
||||
label: 'DURATION',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _figure(
|
||||
icon: Icons.route_rounded,
|
||||
// `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: Icons.inventory_2_rounded,
|
||||
icon: LucideIcons.package,
|
||||
value: '${_trip.totalParcels}',
|
||||
label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS',
|
||||
),
|
||||
@@ -364,7 +555,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
Expanded(
|
||||
child: cash > 0
|
||||
? _figure(
|
||||
icon: Icons.payments_rounded,
|
||||
icon: LucideIcons.banknote,
|
||||
value: '₹${_money(cash)}',
|
||||
label: 'PAYMENT',
|
||||
money: true,
|
||||
@@ -373,8 +564,13 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// four even columns instead of redistributing the space
|
||||
// and shifting the other three figures sideways.
|
||||
: _figure(
|
||||
icon: Icons.money_off_rounded,
|
||||
value: 'None',
|
||||
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,
|
||||
),
|
||||
@@ -413,7 +609,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -427,7 +623,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -487,7 +683,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.05,
|
||||
color: accent,
|
||||
@@ -505,7 +701,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 9.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -532,59 +728,3 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
return parts.length > 1 ? '${buf.toString()}.${parts[1]}' : buf.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// The completion ring, at a third of the area it used to occupy.
|
||||
///
|
||||
/// It was 66dp and sat beside a 25sp headline; it is 34 here and sits beside a
|
||||
/// 15sp line, so it holds the same relative weight in a strip that is a fifth
|
||||
/// of the height. The percentage itself is not printed inside it — the pinned
|
||||
/// trip tab below already prints "50% done", and the fraction next to the ring
|
||||
/// says the same thing in stops, which is the unit the rider works in.
|
||||
class _MiniRing extends StatelessWidget {
|
||||
final int percent;
|
||||
const _MiniRing({required this.percent});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final complete = percent >= 100;
|
||||
final color = complete
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.primary;
|
||||
|
||||
return SizedBox(
|
||||
width: 34.w,
|
||||
height: 34.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox.expand(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: percent / 100),
|
||||
duration: const Duration(milliseconds: 550),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, value, __) => CircularProgressIndicator(
|
||||
value: value,
|
||||
strokeWidth: 3.5,
|
||||
backgroundColor: ColorConstants.borderSubtle,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (complete)
|
||||
Icon(Icons.check_rounded, size: 17.sp, color: color)
|
||||
else
|
||||
Text(
|
||||
'$percent',
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.4,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -29,7 +28,7 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
/// is coming until he goes on — so there the motion stops and the colour
|
||||
/// drains to grey.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class TripEmptyState extends StatefulWidget {
|
||||
class TripEmptyState extends StatelessWidget {
|
||||
/// `Trip 2` — the tab this slot belongs to, named the same way the tabs,
|
||||
/// the hub and his supervisor name it.
|
||||
final String tripLabel;
|
||||
@@ -48,38 +47,9 @@ class TripEmptyState extends StatefulWidget {
|
||||
this.onGoOnDuty,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TripEmptyState> createState() => _TripEmptyStateState();
|
||||
}
|
||||
|
||||
class _TripEmptyStateState extends State<TripEmptyState>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Slow enough to read as breathing rather than blinking.
|
||||
///
|
||||
/// 2s out and 2s back. The pulse on the live-pickup banner runs at 1.1s
|
||||
/// because it marks a job happening *now*; this one marks a wait, and a wait
|
||||
/// that flashes at you is an alarm.
|
||||
///
|
||||
/// Initialised lazily on the declaration rather than in `initState` so hot
|
||||
/// reload cannot leave it unset — see the note in `Bottom_page.dart`.
|
||||
late final AnimationController _breath = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_breath.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final offline = widget.offline;
|
||||
|
||||
// Grey off duty, brand red waiting. The halo and the chip move together on
|
||||
// this one colour, so the screen carries a single decision rather than
|
||||
// three.
|
||||
// Grey off duty, brand red waiting.
|
||||
final Color accent = offline
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.primary;
|
||||
@@ -99,164 +69,98 @@ class _TripEmptyStateState extends State<TripEmptyState>
|
||||
// and adding to that squeezed the chip below on a narrow phone.
|
||||
padding: EdgeInsets.fromLTRB(0, 36.h, 0, 40.h),
|
||||
child: MilerEmptyState(
|
||||
// The component draws its own static halo from `icon`; this one is
|
||||
// supplied as the illustration instead so it can breathe. The icon is
|
||||
// still passed for the fallback path.
|
||||
icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded,
|
||||
illustration: _BreathingHalo(
|
||||
breath: _breath,
|
||||
accent: accent,
|
||||
icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded,
|
||||
// Off duty is a settled state, not a wait — so it holds still.
|
||||
animate: !offline,
|
||||
),
|
||||
title: offline
|
||||
? 'You are off duty'
|
||||
: '${widget.tripLabel} not assigned yet',
|
||||
// The component draws its own static halo from `icon`; the offline
|
||||
// state supplies the breathing one instead. The icon is still passed
|
||||
// for the fallback path.
|
||||
icon: offline ? LucideIcons.wifiOff : LucideIcons.route,
|
||||
// ── Waiting for a trip is a picture, not a diagram ──
|
||||
//
|
||||
// The unassigned slot wore a route glyph inside two breathing rings,
|
||||
// and under it two sentences plus a "Waiting for your hub" chip —
|
||||
// three pieces of furniture explaining an absence. The artwork says
|
||||
// the whole thing in one look (a rider sitting on his scooter beside
|
||||
// a map with a question mark on it), so the screen is the picture and
|
||||
// one line. Off duty keeps the halo: it is a *state*, not a wait, and
|
||||
// it still has a button under it worth pointing at.
|
||||
illustration: offline
|
||||
? _Halo(accent: accent, icon: LucideIcons.wifiOff)
|
||||
: Image.asset(
|
||||
'assets/images/trip_not_assigned.png',
|
||||
fit: BoxFit.contain,
|
||||
// A missing asset degrades to nothing, never to the grey
|
||||
// broken-image box in the middle of an empty screen.
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
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.'
|
||||
: 'Your hub assigns trips as customer slots fill up. This one '
|
||||
'appears here the moment it is ready.',
|
||||
: null,
|
||||
accent: offline ? cta : accent,
|
||||
actionLabel: offline ? 'Go on duty' : null,
|
||||
onAction: offline ? widget.onGoOnDuty : null,
|
||||
// The chip goes under the copy, where a CTA would be if there were one
|
||||
// to offer. On duty there is nothing for the rider to do but wait, and
|
||||
// the honest screen says so rather than inventing a button.
|
||||
footer: offline
|
||||
? null
|
||||
: _WaitingChip(breath: _breath, accent: accent),
|
||||
onAction: offline ? onGoOnDuty : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon on two soft rings that swell and settle together.
|
||||
class _BreathingHalo extends StatelessWidget {
|
||||
final Animation<double> breath;
|
||||
/// The icon on two soft rings.
|
||||
///
|
||||
/// It used to breathe — the rings swelled and settled on a 4s cycle to say
|
||||
/// "still waiting" on an unassigned trip. That screen is an illustration now,
|
||||
/// so the only caller left is the off-duty state, which is a settled state
|
||||
/// rather than a wait and always held still. A controller repeating forever to
|
||||
/// drive a value multiplied by zero is a rebuild per frame for nothing, so the
|
||||
/// animation is gone and this widget — and its host — are stateless again.
|
||||
class _Halo extends StatelessWidget {
|
||||
final Color accent;
|
||||
final IconData icon;
|
||||
final bool animate;
|
||||
|
||||
const _BreathingHalo({
|
||||
required this.breath,
|
||||
required this.accent,
|
||||
required this.icon,
|
||||
required this.animate,
|
||||
});
|
||||
const _Halo({required this.accent, required this.icon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: breath,
|
||||
builder: (context, _) {
|
||||
// 0 → 1 with the ends eased, so the halo pauses at full breath instead
|
||||
// of bouncing off it.
|
||||
final t = animate ? Curves.easeInOut.transform(breath.value) : 0.0;
|
||||
|
||||
return Center(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Outer ring: the widest travel and the faintest tint, so what
|
||||
// the eye catches is movement rather than a pulsing disc.
|
||||
Container(
|
||||
width: 132.w + 16.w * t,
|
||||
height: 132.w + 16.w * t,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.05 * (1 - 0.35 * t)),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 96.w + 8.w * t,
|
||||
height: 96.w + 8.w * t,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.09),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
// The disc holding the glyph stays put. Something has to be
|
||||
// still or the whole group reads as drifting.
|
||||
//
|
||||
// Its shadow is a soft even lift rather than a drop: offset
|
||||
// downward it cast a dark crescent across the ring underneath and
|
||||
// the three circles read as one muddy shape.
|
||||
Container(
|
||||
width: 64.w,
|
||||
height: 64.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accent.withValues(alpha: 0.06),
|
||||
blurRadius: 14,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(icon, size: 28.sp, color: accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `● Waiting for your hub` — the status line, as a pill.
|
||||
///
|
||||
/// It is what turns the screen from a dead end into a state: the rider is not
|
||||
/// being told he has nothing, he is being told the app is still listening.
|
||||
class _WaitingChip extends StatelessWidget {
|
||||
final Animation<double> breath;
|
||||
final Color accent;
|
||||
|
||||
const _WaitingChip({required this.breath, required this.accent});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return Center(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: breath,
|
||||
builder: (context, _) {
|
||||
final t = Curves.easeInOut.transform(breath.value);
|
||||
return Container(
|
||||
width: 7.w,
|
||||
height: 7.w,
|
||||
decoration: BoxDecoration(
|
||||
// Never fully out: a dot that disappears reads as a fault,
|
||||
// not a heartbeat.
|
||||
color: accent.withValues(alpha: 0.45 + 0.55 * t),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
// Flexible, so a rider on the largest system font size gets a
|
||||
// narrower chip rather than a clipped one.
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Waiting for your hub',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.micro.copyWith(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
// Outer ring: the widest and the faintest, so the shape reads as a
|
||||
// halo rather than as a stack of discs.
|
||||
Container(
|
||||
width: 132.w,
|
||||
height: 132.w,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.05),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 96.w,
|
||||
height: 96.w,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.09),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
// The glyph's own disc. Its shadow is spread evenly rather than cast
|
||||
// downward: offset down, it threw a dark crescent across the ring
|
||||
// underneath and the three circles read as one muddy shape.
|
||||
Container(
|
||||
width: 64.w,
|
||||
height: 64.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accent.withValues(alpha: 0.06),
|
||||
blurRadius: 14,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(icon, size: 28.sp, color: accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
@@ -150,7 +152,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
|
||||
final double viewport = _scroll.position.viewportDimension;
|
||||
final double target =
|
||||
((at - 1) * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2);
|
||||
(at * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2);
|
||||
final double clamped = target.clamp(
|
||||
0.0,
|
||||
_scroll.position.maxScrollExtent,
|
||||
@@ -207,17 +209,41 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
/// and every stop keeps its number underneath.
|
||||
Widget _rail() {
|
||||
final total = progress.length;
|
||||
final allDoneNow = _doneCount == total;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double available = constraints.maxWidth;
|
||||
final double gap = _gapFor(available, total);
|
||||
final double content = (total * _node.w) + ((total - 1) * gap);
|
||||
final double gap = _gapFor(available, total + 2);
|
||||
// Two framing nodes and their two connectors ride with the stops.
|
||||
final double content = ((total + 2) * _node.w) + ((total + 1) * gap);
|
||||
// +0.5 so a route that lands exactly on the width is not tipped into a
|
||||
// scroll view by float error.
|
||||
final bool fits = content <= available + 0.5;
|
||||
|
||||
final children = <Widget>[];
|
||||
final children = <Widget>[
|
||||
// ── A rail with one stop on it was a badge ──
|
||||
//
|
||||
// A rider with a single accepted order met one red circled **1** at
|
||||
// the top of his Deliveries page — a counter, or a notification
|
||||
// badge, but not a route. Nothing about it said *journey*, because a
|
||||
// journey needs somewhere to start.
|
||||
//
|
||||
// So the run is framed by the two places it actually runs between:
|
||||
// where he is standing now, and where the day ends. The stops sit
|
||||
// between them, and even one of them now reads as
|
||||
// "here → this pickup → done" rather than as the number one.
|
||||
_EndNode(
|
||||
icon: LucideIcons.locateFixed,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
_connector(
|
||||
progress.first == StopProgress.pending
|
||||
? StopProgress.pending
|
||||
: StopProgress.done,
|
||||
width: gap,
|
||||
),
|
||||
];
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (i > 0) {
|
||||
// A connector is coloured by the stop *behind* it, so the green
|
||||
@@ -227,6 +253,20 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
}
|
||||
children.add(_dot(i));
|
||||
}
|
||||
// Where the day ends: a depot on a logistics loop, home on a round that
|
||||
// finishes at the last door. Capability, never screen position — see
|
||||
// [ServiceProfile.endsAtHub].
|
||||
children.add(_connector(progress.last, width: gap));
|
||||
children.add(
|
||||
_EndNode(
|
||||
icon: ServiceProfile.active.endsAtHub
|
||||
? LucideIcons.warehouse
|
||||
: LucideIcons.house,
|
||||
color: allDoneNow
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.borderStrong,
|
||||
),
|
||||
);
|
||||
|
||||
final int? at = _currentPosition;
|
||||
|
||||
@@ -249,9 +289,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
// Node centre, less half the glyph, so the scooter sits over
|
||||
// the node rather than beside it.
|
||||
left:
|
||||
((at - 1) * (_node.w + gap)) +
|
||||
(_node.w / 2) -
|
||||
(_scooter.w / 2),
|
||||
(at * (_node.w + gap)) + (_node.w / 2) - (_scooter.w / 2),
|
||||
child: _scooterGlyph(),
|
||||
),
|
||||
],
|
||||
@@ -260,13 +298,54 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
|
||||
if (fits) return layers;
|
||||
|
||||
// ── A scrolling rail has to say it scrolls ──
|
||||
//
|
||||
// A 21-stop round draws about seven nodes on a 390pt phone, and the
|
||||
// eighth was sliced clean down its middle by the viewport edge — a
|
||||
// half circle and a connector stopping in mid air, which reads as a
|
||||
// clipping bug rather than as "there is more route this way". The
|
||||
// rider's own count is off-screen for most of the day, so the one
|
||||
// thing this edge has to do is invite the push that reveals it.
|
||||
//
|
||||
// A fade, not a chevron or a shadow: the content *continues*, it is
|
||||
// not covered by something, and the app spends its edges on the
|
||||
// route rather than on furniture pointing at it.
|
||||
//
|
||||
// The stops are `daylightSurface` — this widget's own ground on both
|
||||
// tabs that draw it — so the ramp dissolves into the page instead of
|
||||
// laying a grey band over it. Only the scrolling branch is masked; a
|
||||
// short route ends where the route ends and has nothing to hint at.
|
||||
//
|
||||
// `dstIn` multiplies the layer's alpha by the gradient's, so the
|
||||
// nodes fade rather than being painted over — the mask has to be
|
||||
// sampled over the whole box for that, hence `bounds` unmodified.
|
||||
const double fade = 24;
|
||||
return SizedBox(
|
||||
height: _railHeight,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: layers,
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) => LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: const [
|
||||
Color(0x00FFFFFF),
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0x00FFFFFF),
|
||||
],
|
||||
stops: [
|
||||
0.0,
|
||||
(fade / bounds.width).clamp(0.0, 0.5),
|
||||
1 - (fade / bounds.width).clamp(0.0, 0.5),
|
||||
1.0,
|
||||
],
|
||||
).createShader(bounds),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: layers,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -279,11 +358,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
return SizedBox(
|
||||
width: _scooter.w,
|
||||
height: _scooter.w,
|
||||
child: Icon(
|
||||
Icons.two_wheeler_rounded,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
child: Icon(LucideIcons.bike, size: 20.sp, color: ColorConstants.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -329,7 +404,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
// Fixed against the box: the circle does not grow with the system
|
||||
// font scale, so the digits inside it cannot either.
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
height: 1,
|
||||
color: ink,
|
||||
@@ -407,7 +482,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -419,7 +494,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
allDone ? 'All stops done' : '$done of $total done',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: allDone
|
||||
? ColorConstants.acceptGreen
|
||||
@@ -455,13 +530,18 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
// whole line ellipsises as a unit if the route label is long.
|
||||
Text(
|
||||
allDone
|
||||
? 'Head back to the hub'
|
||||
// Where the day ends follows the capability, not the screen.
|
||||
// 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'
|
||||
: 'Round complete')
|
||||
: _footStatus(total: total, done: done),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: allDone
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.secondaryText,
|
||||
@@ -481,11 +561,48 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
if (at != null) 'On stop $at of $total' else '${total - done} to go',
|
||||
if (skipped > 0) '$skipped skipped',
|
||||
if (left != null && left > Duration.zero)
|
||||
'${formatTripDuration(roundTripDuration(left))} left, then hub',
|
||||
// ── "then hub" is a parcel sentence ──
|
||||
//
|
||||
// A logistics day is a loop: leave the hub empty, fill up at customer
|
||||
// doors, return with the consignments. A meal rider finishes at the
|
||||
// last subscriber's door and goes home — telling him his round ends at
|
||||
// a warehouse he has never visited is a journey nobody is making, and
|
||||
// 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',
|
||||
];
|
||||
return parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of the run: where the rider is standing, or where his day finishes.
|
||||
///
|
||||
/// Deliberately not a numbered node — these are not stops he works, they are
|
||||
/// the two ends the stops sit between. Same footprint as a stop node so the
|
||||
/// rail stays on one axis, hollow so a place he does not *do* anything at never
|
||||
/// competes with one he does.
|
||||
class _EndNode extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _EndNode({required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 34.w,
|
||||
height: 34.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: 2),
|
||||
),
|
||||
child: Icon(icon, size: 15.sp, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hub accent, shared with the Home trip card.
|
||||
final Color kRailHubColor = ColorConstants.tertiary;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
@@ -159,7 +161,12 @@ class _TripTabsState extends State<TripTabs>
|
||||
|
||||
/// The colour a segment settles on once the pill is over it.
|
||||
Color _litColourOf(int tripIndex) {
|
||||
if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText;
|
||||
if (widget.trips.tripAt(tripIndex) == null) {
|
||||
// Selected-but-empty keeps a readable voice — the rider deliberately
|
||||
// looked at this slot, so it answers — but never the brand: there is
|
||||
// nothing here to be mission about.
|
||||
return ColorConstants.secondaryText;
|
||||
}
|
||||
return _percentOf(tripIndex) >= 100
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.primary;
|
||||
@@ -171,16 +178,37 @@ class _TripTabsState extends State<TripTabs>
|
||||
/// Done" is worth knowing from Trip 2 — so for those the two colours are the
|
||||
/// same and nothing changes as the pill goes by.
|
||||
Color _restColourOf(int tripIndex) {
|
||||
if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText;
|
||||
if (widget.trips.tripAt(tripIndex) == null) {
|
||||
// ── An empty slot is furniture, not a peer ──
|
||||
//
|
||||
// At `secondaryText` an unassigned tab was indistinguishable from a
|
||||
// real unselected trip, so a morning screen read as three equal trips
|
||||
// when two of them were placeholders. The disabled token is what the
|
||||
// app already means by "nothing here to act on", and it is exactly one
|
||||
// voice quieter — the slot is still legible, it has simply stopped
|
||||
// competing.
|
||||
return ColorConstants.disabledFill;
|
||||
}
|
||||
return _percentOf(tripIndex) >= 100
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.secondaryText;
|
||||
}
|
||||
|
||||
int _percentOf(int tripIndex) => widget.trips[tripIndex].completionPercent(
|
||||
acceptedIds: widget.acceptedIds,
|
||||
rejectedIds: widget.rejectedIds,
|
||||
);
|
||||
/// ── Slot, not position ──
|
||||
///
|
||||
/// Every lookup here goes through [TripSlots.tripAt]. Tab 1 is the morning
|
||||
/// whether or not the rider has morning work, so `trips[i]` — which meant
|
||||
/// "the i-th trip he happens to have" — would put an afternoon run under the
|
||||
/// Trip 1 tab on a day with no morning stops, and the rider's Trip 2 would
|
||||
/// not be the hub's.
|
||||
int _percentOf(int tripIndex) =>
|
||||
widget.trips
|
||||
.tripAt(tripIndex)
|
||||
?.completionPercent(
|
||||
acceptedIds: widget.acceptedIds,
|
||||
rejectedIds: widget.rejectedIds,
|
||||
) ??
|
||||
0;
|
||||
|
||||
/// True when this trip has a stop in progress — the rider is on it now.
|
||||
///
|
||||
@@ -231,7 +259,10 @@ class _TripTabsState extends State<TripTabs>
|
||||
margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h),
|
||||
padding: EdgeInsets.all(3.w),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
// Layer 1 on the canvas, same as every working surface on Home.
|
||||
// `neutralLight` separated from the old cream backing by 1.02:1 —
|
||||
// a track you had to be told about.
|
||||
color: MilerSurface.working,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
// LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder
|
||||
@@ -286,7 +317,9 @@ class _TripTabsState extends State<TripTabs>
|
||||
// tint, so the two controls a rider uses most looked
|
||||
// like they came from different apps.
|
||||
color: ColorConstants.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
@@ -304,7 +337,9 @@ class _TripTabsState extends State<TripTabs>
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: markColour,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -334,14 +369,13 @@ class _TripTabsState extends State<TripTabs>
|
||||
return Expanded(
|
||||
child: _Tab(
|
||||
label: Trip.tabLabel(i),
|
||||
percent: i < widget.trips.length
|
||||
? _percentOf(i)
|
||||
: 0,
|
||||
assigned: i < widget.trips.length,
|
||||
percent: _percentOf(i),
|
||||
assigned: widget.trips.tripAt(i) != null,
|
||||
selected: i == widget.selectedIndex,
|
||||
active:
|
||||
i < widget.trips.length &&
|
||||
_isRunning(widget.trips[i]),
|
||||
active: switch (widget.trips.tripAt(i)) {
|
||||
final t? => _isRunning(t),
|
||||
_ => false,
|
||||
},
|
||||
lit: lit,
|
||||
restColour: _restColourOf(i),
|
||||
litColour: _litColourOf(i),
|
||||
@@ -479,8 +513,14 @@ class _PinnedSurface extends StatelessWidget {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
decoration: BoxDecoration(
|
||||
// Opaque, or the stop cards show through the bar as they pass under it.
|
||||
color: ColorConstants.surface,
|
||||
// Opaque, or the stop cards show through the bar as they pass under
|
||||
// it. And it is the CANVAS, not `ColorConstants.surface` — that token
|
||||
// is the warm near-white the surface audit flagged as "warm, unlike
|
||||
// everything else", and here it painted a cream band across the top
|
||||
// of a cool grey page. The bar is the page holding its position, so
|
||||
// it wears the page's own ground; the white segmented track stands on
|
||||
// it at the full canvas step.
|
||||
color: MilerSurface.canvas,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: floating ? ColorConstants.borderStrong : Colors.transparent,
|
||||
@@ -552,7 +592,10 @@ class _Tab extends StatelessWidget {
|
||||
final Color accent = Color.lerp(restColour, litColour, lit)!;
|
||||
|
||||
final String status = !assigned
|
||||
? 'Not set'
|
||||
// "Not set" read as a chore waiting for the rider. The hub fills
|
||||
// these slots; all this tab can honestly say is that nothing has
|
||||
// arrived for it yet.
|
||||
? 'None yet'
|
||||
: complete
|
||||
? 'Done'
|
||||
: active
|
||||
@@ -581,7 +624,9 @@ class _Tab extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
// A placeholder slot also gives up the headline weight —
|
||||
// colour alone was not enough separation on a sunlit LCD.
|
||||
fontWeight: assigned ? FontWeight.w700 : FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -598,7 +643,7 @@ class _Tab extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -658,17 +703,17 @@ class _OverflowMenu extends StatelessWidget {
|
||||
Trip.tabLabel(i),
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
i < trips.length ? trips[i].slotLabel : 'Not assigned',
|
||||
trips.tripAt(i)?.slotLabel ?? 'Not assigned',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -684,7 +729,7 @@ class _OverflowMenu extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.expand_more_rounded,
|
||||
LucideIcons.chevronDown,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
@@ -692,7 +737,7 @@ class _OverflowMenu extends StatelessWidget {
|
||||
'+${hidden.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user