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:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -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)}';
}

View File

@@ -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

View File

@@ -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,
),

View 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,
),
],
);
}
}

View File

@@ -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,
),
),

File diff suppressed because it is too large Load Diff

View File

@@ -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,
),

View 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,
),
],
),
),
);
}
}

View 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);
}

View File

@@ -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),
);
},
),
),
],

View File

@@ -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 '—';

View File

@@ -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

View File

@@ -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),
),
],
),
);

View File

@@ -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;

View File

@@ -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,
),