first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
import 'package:flutter/material.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/Font_constant.dart';
import 'package:miler/widget/Bottom_page.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ACCEPTED PILL — how many stops you have taken, and the way to them
///
/// Home is where the rider *decides*; Bookings is where he *works*. The bridge
/// between the two used to be a "Continue trip" button sitting under the last
/// hub row of a trip — which meant it only existed once that particular trip
/// had nothing left to decide, it moved as the route grew, and it was below the
/// fold on any route longer than about four stops. A rider who had accepted
/// 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
/// 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
/// the foot of the page and never appear together. A selection in progress is
/// the more urgent of the two and wins the slot.
///
/// The count comes from the accepted store, which drops a booking once it is
/// picked, so the number is "waiting for you on Bookings" rather than "accepted
/// at some point today".
/// ─────────────────────────────────────────────────────────────────────────
class AcceptedPill extends StatelessWidget {
/// Accepted bookings still waiting to be worked. Zero renders nothing.
final int count;
/// Jumps to the Bookings tab.
final VoidCallback onTap;
const AcceptedPill({super.key, required this.count, required this.onTap});
@override
Widget build(BuildContext context) {
if (count <= 0) return const SizedBox.shrink();
final radius = BorderRadius.circular(DesignConstants.radiusFull);
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
// body, so `SafeArea` knows nothing about it and anything pinned to the
// bottom lands underneath. See [BottomPage.bottomInset].
//
// A fixed inset now. This used to follow a `navVisible` notifier up and
// down, because the bar left the screen entirely on scroll. It compacts
// instead of leaving, so there is no longer a state in which the room is
// free — and chasing the 20pt difference re-laid this out every time the
// rider changed scroll direction, for a gap he would never notice.
return Padding(
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Align(
// Right-aligned and sized to its content: this is an ambient way
// through, not the action the screen is for. A full-width green bar
// here would out-rank the accept the rider is actually on Home to
// make.
alignment: Alignment.centerRight,
child: Semantics(
button: true,
label: count == 1
? '1 accepted booking. Go to Bookings.'
: '$count accepted bookings. Go to Bookings.',
child: Material(
color: ColorConstants.primary,
borderRadius: radius,
elevation: 0,
child: InkWell(
onTap: onTap,
borderRadius: radius,
child: Container(
// 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),
decoration: BoxDecoration(
borderRadius: radius,
boxShadow: DesignConstants.shadowLg,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// The count as a badge, not as a word in a sentence: it
// 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,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
child: Text(
'$count',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
height: 1,
letterSpacing: -0.3,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 9.w),
Text(
count == 1 ? 'booking accepted' : 'bookings accepted',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.1,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 2.w),
Icon(
Icons.chevron_right_rounded,
size: 20.sp,
color: Colors.white,
),
],
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,481 @@
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';
/// ─────────────────────────────────────────────────────────────────────────
/// 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".
///
/// Three things were wrong with that, and none of them was the styling:
///
/// • **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].
///
/// 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.
///
/// 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.
/// ─────────────────────────────────────────────────────────────────────────
class DutySheet extends StatelessWidget {
/// True when this sheet is asking to go **on** duty.
final bool goingOnline;
/// Runs on a completed slide, and owns the work: the pending-booking check,
/// the API call, the polling restart. Returns true when duty actually
/// changed.
///
/// The knob belongs to the sheet, not the caller — it springs back on every
/// outcome, and the sheet closes itself only on a true. That is why this is a
/// `Future<bool>` and not the package's controller: the controller type is
/// not exported from `slide_to_submit_button`, so naming it here would mean
/// reaching into the package's `src/`.
final Future<bool> Function() onConfirm;
const DutySheet({
super.key,
required this.goingOnline,
required this.onConfirm,
});
/// Opens the sheet. The caller keeps the toggle logic; this only presents it.
static Future<void> show(
BuildContext context, {
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),
);
}
/// Green to start, brand red to stop.
///
/// Green matches the duty switch's own "on" fill, so the sheet is visibly
/// about the control that opened it. Stopping is not grey: grey is what a
/// disabled control looks like, and this is the most consequential button on
/// the screen.
Color get _accent =>
goingOnline ? ColorConstants.success : ColorConstants.primary;
@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,
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,
),
),
),
],
),
);
}
}
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,
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.slateText,
),
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/widget/Bottom_page.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// The live strip that sits at the foot of Home and Bookings while a pickup is
/// under way — the rider's way back into the stop he is already on.
///
/// ── One banner, not two ──
///
/// Home and Bookings each had their own copy. They had drifted into different
/// widgets: one a gradient card with a double drop shadow, an order-id badge and
/// a status pill; the other a flat red box with a LIVE chip and a hard 40-
/// character address truncation. Same job, same data, two designs — which is how
/// the same screen came to look older on one tab than the other.
///
/// ── It has to clear the nav bar ──
///
/// Both copies pinned themselves to `bottom: 0`. The nav bar *floats*: it is a
/// `Positioned` pill in the shell's `Stack`, not a `bottomNavigationBar`, so the
/// Scaffold reserves nothing for it and `SafeArea` cannot see it. The banner
/// therefore sat underneath it, with the bar covering the tap target of the one
/// control telling the rider he has a job open.
///
/// 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 {
final List<Map<String, dynamic>> activePickups;
final void Function(Map<String, dynamic>) onTap;
const ActivePickupBanner({
super.key,
required this.activePickups,
required this.onTap,
});
@override
State<ActivePickupBanner> createState() => _ActivePickupBannerState();
}
class _ActivePickupBannerState extends State<ActivePickupBanner>
with SingleTickerProviderStateMixin {
/// The live dot actually pulses.
///
/// A static dot beside the word LIVE is a claim the interface does not back
/// up; movement is what makes "there is a job running right now" read without
/// being read. Initialised lazily on the declaration rather than in
/// `initState` so hot reload cannot leave it unset — see the note in
/// `Bottom_page.dart`.
/// Created on first *use*, not on first touch of any kind.
///
/// This was a `late final` initialised on the declaration, which meant
/// `dispose()` calling `_pulse.dispose()` *constructed* the controller during
/// teardown whenever it had never been used — and `build` returns early with
/// no active pickups, so that is the normal path when a rider's last stop
/// finishes and the banner unmounts. `createTicker` then looked up
/// `TickerMode` on a deactivated element and threw. Two of the app's own
/// tests caught it; it needs the nullable so dispose can ask "was there
/// ever one?" instead of answering "there is now".
AnimationController? _pulseController;
AnimationController get _pulse => _pulseController ??= AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
)..repeat(reverse: true);
@override
void dispose() {
_pulseController?.dispose();
super.dispose();
}
String _address(Map<String, dynamic> pickup) =>
(pickup['pickupaddress'] ??
pickup['PickupAddress'] ??
pickup['address'] ??
'')
.toString()
.trim();
/// Where the rider has got to on this stop — from the one place the app
/// names its statuses, so the banner cannot say "At pickup" while the card
/// behind it says something else about the same stop. See [StopStatusX.label].
String _status(Map<String, dynamic> pickup) => stopStatusOf(pickup).label;
@override
Widget build(BuildContext context) {
if (widget.activePickups.isEmpty) return const SizedBox.shrink();
final pickup = widget.activePickups.first;
final count = widget.activePickups.length;
final address = _address(pickup);
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
// body, so `SafeArea` knows nothing about it and anything pinned to the
// bottom lands underneath. See [BottomPage.bottomInset].
//
// A fixed inset now. This used to follow a `navVisible` notifier up and
// down, because the bar left the screen entirely on scroll. It compacts
// instead of leaving, so there is no longer a state in which the room is
// free — and chasing the 20pt difference re-laid this out every time the
// rider changed scroll direction, for a gap he would never notice.
return Padding(
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
child: Padding(
// 16 — the same gutter the nav bar above it uses, so the two stack as
// one column rather than as two floating objects of different widths.
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _Card(
pulse: _pulse,
count: count,
address: address,
status: _status(pickup),
onTap: () => widget.onTap(pickup),
),
),
);
}
}
class _Card extends StatelessWidget {
final Animation<double> pulse;
final int count;
final String address;
final String status;
final VoidCallback onTap;
const _Card({
required this.pulse,
required this.count,
required this.address,
required this.status,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: ColorConstants.primary,
// 20 — between the stop cards' 16 and the nav bar's 32, which is where it
// sits in the visual stack.
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
clipBehavior: Clip.antiAlias,
// Tinted rather than black: a neutral shadow under a saturated red surface
// reads as grime. The gradient and second shadow the Home copy carried are
// gone — one flat fill and one shadow is the language the rest of the app
// now speaks.
shadowColor: ColorConstants.primary.withValues(alpha: 0.45),
elevation: 10,
child: InkWell(
onTap: onTap,
splashColor: Colors.white.withValues(alpha: 0.10),
highlightColor: Colors.white.withValues(alpha: 0.06),
child: Padding(
padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h),
child: Row(
children: [
Container(
width: 42.w,
height: 42.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Icon(
Icons.two_wheeler_rounded,
color: Colors.white,
size: 22.sp,
),
),
SizedBox(width: 11.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_LiveChip(pulse: pulse),
SizedBox(width: 7.w),
// Flexible, not bare: this row has a fixed-width chip on
// one side and a title that grows with the pickup count
// and the text scale on the other. Every unflexed row of
// that shape in this app has overflowed eventually.
Flexible(
child: Text(
count > 1
? '$count Active Pickups'
: 'Active Pickup',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
SizedBox(height: 3.h),
Row(
children: [
Icon(
Icons.place_rounded,
color: Colors.white.withValues(alpha: 0.72),
size: 13.sp,
),
SizedBox(width: 3.w),
Expanded(
child: Text(
// Ellipsised by the layout, not chopped at 40
// characters — the old truncation cut mid-word and
// then appended its own "…", which on a narrow phone
// ellipsised a second time.
address.isEmpty ? status : '$status · $address',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.82),
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
],
),
),
SizedBox(width: 8.w),
Container(
width: 30.w,
height: 30.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_forward_rounded,
color: Colors.white,
size: 16.sp,
),
),
],
),
),
),
);
}
}
/// The word LIVE, with a dot that breathes.
class _LiveChip extends StatelessWidget {
final Animation<double> pulse;
const _LiveChip({required this.pulse});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(6.w, 2.5.h, 7.w, 2.5.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.2),
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 text beside it every frame.
return SizedBox(
width: 7.w,
height: 7.w,
child: Stack(
alignment: Alignment.center,
children: [
Transform.scale(
scale: 1.0 + 1.1 * pulse.value,
child: DecoratedBox(
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(
alpha: 0.55 * (1 - pulse.value),
),
shape: BoxShape.circle,
),
child: SizedBox(width: 7.w, height: 7.w),
),
),
Container(
width: 6.w,
height: 6.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
),
],
),
);
},
),
SizedBox(width: 5.w),
Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 9.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.7,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,392 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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';
/// ─────────────────────────────────────────────────────────────────────────
/// Route brief — the VRP facts a miler needs before touching a single stop.
///
/// A miler's day is one solved VRP instance: the hub admin has already fixed
/// the sequence, so the rider never re-optimises anything. What he *does* need
/// is to see the shape of the solution he's been handed, because every
/// classic VRP constraint has a real consequence on the bike:
///
/// • **Capacity (CVRP)** — parcels + weight decide whether the load fits the
/// box. Finding out at stop 9 is too late.
/// • **Time windows (VRPTW)** — each booking has a slot; arriving after it
/// closes is a failed stop. The rider needs to know how many are at risk.
/// • **Route duration limit** — the shift is the hard cap on the route. Time
/// left ÷ stops left is the per-stop budget he's actually racing against.
/// • **Round trip to the depot** — the route ends where it started, so the
/// return leg is part of the distance, not an afterthought.
/// • **Cash on the route** — not a textbook VRP term, but the rider carries
/// it, so it belongs in the same glance.
///
/// Everything here is derived from data the app already has (stop coordinates,
/// quantities, expected pickup times, the shift window in prefs) — no new
/// endpoint required.
/// ─────────────────────────────────────────────────────────────────────────
class RouteBrief {
/// Stops still to be worked on this route.
final int stops;
/// Total parcels to be collected — the load against box capacity.
final int parcels;
/// Total declared weight in kg (0 when the backend doesn't send weights).
final double weightKg;
/// Cash/UPI still to be collected across the route.
final double collectAmount;
/// Approximate round-trip length: rider → stop 1 → … → stop N → back to
/// start. Straight-line (haversine), so it under-reads real road distance —
/// always shown with a `≈`.
final double routeKm;
/// Shift window as stored by the profile sync, e.g. `09:00:00`.
final String shiftStart;
final String shiftEnd;
/// True only when both ends of the window are present and parseable.
final bool shiftSet;
/// Time remaining until the shift closes. Null when the shift isn't set;
/// `Duration.zero` when the window has already passed.
final Duration? timeLeft;
/// Stops whose booked slot has already passed — these are failing their
/// time window right now.
final int overdueStops;
/// Stops whose slot closes within the next 30 minutes.
final int dueSoonStops;
const RouteBrief({
required this.stops,
required this.parcels,
required this.weightKg,
required this.collectAmount,
required this.routeKm,
required this.shiftStart,
required this.shiftEnd,
required this.shiftSet,
required this.timeLeft,
required this.overdueStops,
required this.dueSoonStops,
});
/// A brief that knows nothing — no shift, no load, no pace.
///
/// For callers that render a trip without the page-level context, chiefly
/// [TripCard] used as a whole widget in tests. `shiftSet: false` is the
/// meaningful part: every consumer already treats that as "say nothing about
/// time", so an absent brief degrades to the same state as an unset shift
/// rather than needing a null check at each use.
static const RouteBrief empty = RouteBrief(
stops: 0,
parcels: 0,
weightKg: 0,
collectAmount: 0,
routeKm: 0,
shiftStart: '',
shiftEnd: '',
shiftSet: false,
timeLeft: null,
overdueStops: 0,
dueSoonStops: 0,
);
/// Average time available per remaining stop — the pace the rider has to
/// hold to finish inside the shift. Null when either half is unknown.
Duration? get perStopBudget {
final left = timeLeft;
if (left == null || stops <= 0 || left <= Duration.zero) return null;
return Duration(seconds: left.inSeconds ~/ stops);
}
/// True when the remaining stops can't realistically fit in the time left
/// (under 6 minutes a stop, which is faster than a pickup can be done).
bool get isPaceAtRisk {
final budget = perStopBudget;
return budget != null && budget.inMinutes < 6;
}
String get shiftWindowLabel {
if (!shiftSet) return 'Shift not set';
return '${_prettyTime(shiftStart)} – ${_prettyTime(shiftEnd)}';
}
/// Builds the brief from the pending-stop map the Home page already holds.
///
/// [extraStops] are stops that have left the pending list but are still on
/// the route (accepted, awaiting pickup) — they count towards the load,
/// distance and time-window totals just like pending ones.
///
/// [riderLat]/[riderLng] are optional: without them the route length falls
/// back to chaining the stops themselves (and to the per-stop `kms` field
/// the backend sends, when coordinates are missing altogether).
factory RouteBrief.from({
required List<String> orderIds,
required Map<String, Map<String, dynamic>> ordersMap,
required String shiftStart,
required String shiftEnd,
List<Map<String, dynamic>> extraStops = const [],
double? riderLat,
double? riderLng,
DateTime? now,
}) {
final clock = now ?? DateTime.now();
int parcels = 0;
double weight = 0;
double collect = 0;
int overdue = 0;
int dueSoon = 0;
double fallbackKm = 0;
final points = <({double lat, double lng})>[];
final allStops = <Map<String, dynamic>>[
for (final id in orderIds)
if (ordersMap[id] != null) ordersMap[id]!,
...extraStops,
];
for (final stop in allStops) {
parcels += _toInt(stop['Quantity'] ?? stop['quantity']);
weight += _toDouble(stop['weight'] ?? stop['Weight']);
collect += _toDouble(stop['collectionamt'] ?? stop['pickupamt']);
fallbackKm += _toDouble(stop['kms'] ?? stop['km']);
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (lat != 0 && lng != 0) points.add((lat: lat, lng: lng));
final due = _parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
stop['slotendtime'],
);
if (due != null) {
if (due.isBefore(clock)) {
overdue++;
} else if (due.difference(clock) <= const Duration(minutes: 30)) {
dueSoon++;
}
}
}
// Round trip: start at the rider (his current position stands in for the
// hub he left), walk the fixed sequence, then close the loop back to it.
double routeKm = 0;
final ({double lat, double lng})? origin =
(riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0)
? (lat: riderLat, lng: riderLng)
: null;
if (points.isNotEmpty) {
final chain = <({double lat, double lng})>[
if (origin != null) origin,
...points,
if (origin != null) origin,
];
for (var i = 0; i < chain.length - 1; i++) {
routeKm += _haversineKm(chain[i], chain[i + 1]);
}
}
if (routeKm == 0) routeKm = fallbackKm;
final start = _parseClock(shiftStart, clock);
final end = _parseClock(shiftEnd, clock);
final shiftSet = start != null && end != null;
Duration? left;
if (shiftSet) {
// Overnight shifts (22:00 → 06:00) roll the end into tomorrow.
final adjustedEnd = end.isBefore(start)
? end.add(const Duration(days: 1))
: end;
final diff = adjustedEnd.difference(clock);
left = diff.isNegative ? Duration.zero : diff;
}
return RouteBrief(
stops: allStops.length,
parcels: parcels,
weightKg: weight,
collectAmount: collect,
routeKm: routeKm,
shiftStart: shiftStart,
shiftEnd: shiftEnd,
shiftSet: shiftSet,
timeLeft: left,
overdueStops: overdue,
dueSoonStops: dueSoon,
);
}
// ── parsing helpers ────────────────────────────────────────────────────
static int _toInt(dynamic v) =>
v == null ? 0 : int.tryParse(v.toString().trim()) ?? 0;
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
// Strip units/currency the backend sometimes bakes into the string.
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
static DateTime? _parseTimestamp(dynamic v) {
if (v == null) return null;
return DateTime.tryParse(v.toString().trim());
}
/// Parses `HH:mm[:ss]` or `h:mm AM/PM` onto [onDay].
static DateTime? _parseClock(String raw, DateTime onDay) {
final value = raw.trim();
if (value.isEmpty) return null;
final match = RegExp(
r'^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])?$',
).firstMatch(value);
if (match == null) return null;
var hour = int.tryParse(match.group(1) ?? '') ?? -1;
final minute = int.tryParse(match.group(2) ?? '') ?? -1;
final second = int.tryParse(match.group(3) ?? '0') ?? 0;
if (hour < 0 || minute < 0 || minute > 59) return null;
final meridiem = match.group(4)?.toUpperCase();
if (meridiem == 'PM' && hour < 12) hour += 12;
if (meridiem == 'AM' && hour == 12) hour = 0;
if (hour > 23) return null;
return DateTime(onDay.year, onDay.month, onDay.day, hour, minute, second);
}
static String _prettyTime(String raw) {
final parsed = _parseClock(raw, DateTime.now());
if (parsed == null) return raw.trim();
final hour12 = parsed.hour % 12 == 0 ? 12 : parsed.hour % 12;
final minute = parsed.minute.toString().padLeft(2, '0');
return '$hour12:$minute ${parsed.hour < 12 ? 'AM' : 'PM'}';
}
static double _haversineKm(
({double lat, double lng}) a,
({double lat, double lng}) b,
) {
const earthRadiusKm = 6371.0;
final dLat = _rad(b.lat - a.lat);
final dLng = _rad(b.lng - a.lng);
final h =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_rad(a.lat)) *
math.cos(_rad(b.lat)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
return earthRadiusKm * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
}
static double _rad(double deg) => deg * math.pi / 180.0;
}
/// Formats a duration the way a rider reads a clock: `2h 15m`, `45m`.
String formatDuration(Duration d) {
if (d.inMinutes < 1) return 'under a minute';
if (d.inHours < 1) return '${d.inMinutes}m';
final minutes = d.inMinutes.remainder(60);
return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m';
}
/// ─────────────────────────────────────────────────────────────────────────
/// Shift banner — the assigned shift window, and nothing else.
///
/// One state. When the hub has set a shift this is a quiet informational strip:
/// confirmation, not news, so it stays visually silent and never competes with
/// the stop list. When no shift is set the widget renders **nothing**.
///
/// ── Why the "No shift set for today" box is gone ──
///
/// It had already been de-escalated once, from a pulsing hazard-triangle alert
/// down to a calm amber row, and the reasoning that drove that step applies all
/// the way to zero: the rider cannot fix a missing shift, did not cause it, and
/// is not blocked by it — the trips below still load and he can still ride
/// them. The row's own subtitle admitted as much ("your hub manager sets it ·
/// trips still work").
///
/// So it was a permanent amber box, at the top of the first screen, telling the
/// rider about someone else's unfinished admin. Its Retry button could only
/// re-read the same local prefs, because there is no endpoint to re-pull the
/// window — so the one affordance it offered mostly did nothing. Removing it
/// costs the rider no information he can act on and gives the route back the
/// top of the screen.
///
/// The window still governs duty close and the shift-end alarm; it is simply
/// not this widget's job to editorialise when it is absent.
/// ─────────────────────────────────────────────────────────────────────────
class ShiftBanner extends StatelessWidget {
final RouteBrief brief;
const ShiftBanner({super.key, required this.brief});
@override
Widget build(BuildContext context) {
if (!brief.shiftSet) return const SizedBox.shrink();
return _buildSetState();
}
// ── Shift assigned: quiet, informational ───────────────────────────────
Widget _buildSetState() {
final left = brief.timeLeft;
final ended = left != null && left == Duration.zero;
return Container(
padding: EdgeInsets.symmetric(vertical: 9.h, horizontal: 12.w),
decoration: BoxDecoration(
color: ColorConstants.surfaceContainerLow,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(color: ColorConstants.borderStrong, width: 1.5),
),
child: Row(
children: [
Icon(
Icons.access_time_rounded,
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,
fontFamily: FontConstants.fontFamily,
),
),
const Spacer(),
if (left != null)
Text(
ended ? 'Shift ended' : '${formatDuration(left)} left',
style: TextStyle(
color: ended
? ColorConstants.error
: (brief.isPaceAtRisk
? ColorConstants.warning
: ColorConstants.secondaryText),
fontSize: 11.5.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.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/Font_constant.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/widget/Bottom_page.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// SELECTION BAR — the contextual action for a multi-stop pick
///
/// Appears the moment the rider ticks a stop and leaves when he unticks the
/// last one. While it is up it owns the bulk action: the inline "Accept all N
/// stops" button in the trip's footer stands down, so there is never a choice
/// of two bulk buttons pointing at different sets of stops.
///
/// ── Why this floats instead of sitting in the list ──
///
/// It used to be an inline button in the footer, below the stop rows. That put
/// a *response to a direct action* at the bottom of a scroll: tick a stop near
/// the top of a six-stop route and the button confirming what you had just done
/// was off-screen. The rider had no feedback that the tick had registered
/// beyond the tick itself, and no way to act on it without scrolling past every
/// remaining stop.
///
/// A selection is transient and it is *modal in spirit* — the screen is in a
/// different mode while it is non-empty. Every app that does multi-select the
/// way riders already know it, Gmail and Google Photos included, answers that
/// with a contextual bar pinned to the edge of the viewport: it appears with
/// the selection, it is always reachable, and it leaves without a trace. The
/// count lives on it, so the rider never has to go looking for how many he has
/// picked.
///
/// ── Clearing ──
///
/// The ✕ is on the left, where a dismiss belongs and where it cannot be hit by
/// a thumb going for Accept. Without it the only way out of a selection was to
/// untick each stop by hand, which on a six-stop route is six taps to undo one
/// mistake.
/// ─────────────────────────────────────────────────────────────────────────
class SelectionBar extends StatelessWidget {
/// How many stops are ticked. Zero renders nothing.
final int count;
/// Accepts exactly the ticked stops.
final VoidCallback? onAccept;
/// Declines exactly the ticked stops, after asking why. Null hides the
/// action.
final VoidCallback? onReject;
/// Drops the whole selection.
final VoidCallback onClear;
/// True while the accept is in flight.
final bool busy;
const SelectionBar({
super.key,
required this.count,
required this.onAccept,
required this.onClear,
this.onReject,
this.busy = false,
});
@override
Widget build(BuildContext context) {
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
// body, so `SafeArea` knows nothing about it and anything pinned to the
// bottom lands underneath. See [BottomPage.bottomInset].
//
// A fixed inset now. This used to follow a `navVisible` notifier up and
// down, because the bar left the screen entirely on scroll. It compacts
// instead of leaving, so there is no longer a state in which the room is
// free — and chasing the 20pt difference re-laid this out every time the
// rider changed scroll direction, for a gap he would never notice.
return Padding(
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
// A real lift, not the page's usual whisper: this is the one surface
// on Home that is temporarily above everything else, and it has to
// read that way against a list of white cards.
elevation: 0,
child: Container(
padding: EdgeInsets.fromLTRB(6.w, 8.h, 10.w, 8.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle),
boxShadow: DesignConstants.shadowLg,
),
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,
),
),
)
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,
),
),
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,
),
),
],
),
),
),
),
);
}
}
class _ClearButton extends StatelessWidget {
final VoidCallback? onTap;
const _ClearButton({this.onTap});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Clear selection',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: SizedBox(
width: ButtonSizes.minTapTarget,
height: ButtonSizes.minTapTarget,
child: Icon(
Icons.close_rounded,
size: 20.sp,
color: ColorConstants.secondaryText,
),
),
),
);
}
}

View File

@@ -0,0 +1,596 @@
import 'package:flutter/material.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/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/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// STOP DETAIL SHEET
///
/// Opens when the rider taps a stop on the trip card. It answers the two
/// questions the list deliberately does not have room for:
///
/// "Where actually is this?" → a map with the hub, the stop, and the leg
/// between them
/// "What exactly is here?" → the full address, contact, parcel counts,
/// cash, notes, OTP
///
/// This is why the card above can afford to truncate. The list is for
/// scanning — one line of identity, two of address — and everything it drops
/// lives here, one tap away. A list that tries to show everything shows
/// nothing well.
///
/// The map is deliberately small and non-interactive: it is an orientation
/// aid, not a navigation surface. Turn-by-turn is Google Maps' job, and the
/// Navigate button hands off to it.
/// ─────────────────────────────────────────────────────────────────────────
class StopDetailSheet extends StatelessWidget {
final Map<String, dynamic> stop;
/// Stop's position in the route, 1-based.
final int stopNumber;
/// Where the rider is now — draws the "you are here" leg on the map.
final double? riderLat;
final double? riderLng;
const StopDetailSheet({
super.key,
required this.stop,
required this.stopNumber,
this.riderLat,
this.riderLng,
});
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
static Future<void> show(
BuildContext context, {
required Map<String, dynamic> stop,
required int stopNumber,
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)),
),
builder: (_) => StopDetailSheet(
stop: stop,
stopNumber: stopNumber,
riderLat: riderLat,
riderLng: riderLng,
),
);
}
double _d(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString().trim()) ?? 0;
}
String _s(List<String> keys) {
for (final k in keys) {
final v = stop[k];
if (v != null && v.toString().trim().isNotEmpty && v.toString() != '0') {
return v.toString().trim();
}
}
return '';
}
@override
Widget build(BuildContext context) {
final kind = stopKindOf(stop);
final lat = _d(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _d(stop['pickuplon'] ?? stop['PickupLon']);
final hasLocation = lat != 0 && lng != 0;
final name = _s(['pickupcustomer', 'tenantname']);
final address = _s(['pickupaddress', 'PickupAddress']);
final phone = _s(['pickupcontactno']);
final notes = _s(['notes', 'Notes']);
final otp = stopOtp(stop);
final deliver = deliveryParcelCount(stop);
final collect = pickupParcelCount(stop);
final cash = stopCollectionAmount(stop);
final meters = RouteMetricsHelper.metersToStop(
stop,
riderLat: riderLat,
riderLng: riderLng,
);
return DraggableScrollableSheet(
initialChildSize: 0.78,
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,
),
),
SizedBox(height: 16.h),
],
_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),
],
),
),
],
),
);
}
Widget _header(StopKind kind, String name) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34.w,
height: 34.w,
alignment: Alignment.center,
decoration: BoxDecoration(color: kind.accent, shape: BoxShape.circle),
child: Text(
'$stopNumber',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 11.w),
Expanded(
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),
Text(
name.isEmpty ? 'Stop $stopNumber' : name,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.15,
color: ColorConstants.slateText,
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.
///
/// ── Why it is not lite mode any more ──
///
/// It was. Lite mode renders the map once, into a bitmap, at whatever size the
/// view happens to have when the snapshot is taken — and it does not re-render
/// when that size changes. Everything about where this map lives conspires
/// against that: it is built by [AfterEntrance] a beat *after* the sheet has
/// begun to settle, inside a [DraggableScrollableSheet] whose height is still
/// moving, in a list the rider can drag. The snapshot was therefore taken
/// against a box that no longer existed, and what he saw was half a map with
/// dead space under it.
///
/// A normal map lays out against its box and repaints when the box changes, so
/// the failure is structural rather than a timing race to be tuned. The cost
/// lite mode was buying — not building a platform view during the entrance —
/// is already paid for by [AfterEntrance] holding it back, so nothing is lost
/// by dropping it.
Widget _map(double lat, double lng, StopKind kind) {
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(
height: 170.h,
// ── Why the map waits ──
//
// This sheet used to arrive with a jolt, and the curve was never the
// problem: a GoogleMap is an Android platform view, and building one
// costs the UI thread a couple of hundred milliseconds. Constructed in
// the same frame the sheet begins to rise, it swallowed most of the
// entrance — the animation ran, but the frames were never drawn, so the
// sheet appeared to snap into place.
//
// [AfterEntrance] holds it until the sheet has finished rising. The
// motion is now spent on cheap widgets, and the map fades up a beat
// 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(),
),
],
),
),
),
),
);
}
Widget _distanceLine(double? meters) {
final distance = RouteMetricsHelper.formatDistance(meters);
final travel = RouteMetricsHelper.travelTime(meters);
if (distance == '—') return const SizedBox.shrink();
return Row(
children: [
Icon(
Icons.near_me_rounded,
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,
),
),
if (travel > Duration.zero) ...[
Text(
' · ${RouteMetricsHelper.formatDuration(travel)} ride',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
],
);
}
Widget _sectionLabel(String text) => Text(
text,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
);
Widget _actionRow({
required IconData icon,
required Color color,
required String title,
required String detail,
}) {
return Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Row(
children: [
Container(
width: 32.w,
height: 32.w,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(icon, size: 17.sp, color: color),
),
SizedBox(width: 10.w),
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,
),
),
],
),
);
}
Widget _detailGrid() {
final rows = <MapEntry<String, String>>[
MapEntry('Order', _s(['orderid', 'OrderId'])),
MapEntry('Product', _s(['description', 'productname', 'product'])),
MapEntry('Weight', () {
final w = _s(['weight', 'Weight']);
return w.isEmpty ? '' : (w.contains('kg') ? w : '$w kg');
}()),
MapEntry('Booked slot', () {
final raw = _s(['expected_pickup_time']);
final t = DateTime.tryParse(raw);
return t == null ? '' : RouteMetricsHelper.formatClock(t);
}()),
MapEntry('Contact', _s(['pickupcontactno'])),
].where((e) => e.value.isNotEmpty).toList();
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,
),
),
),
Expanded(
child: Text(
r.value,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
],
);
}
Widget _noteBox(String notes) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
decoration: BoxDecoration(
color: ColorConstants.warningContainer,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(
color: ColorConstants.warning.withValues(alpha: 0.35),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.sticky_note_2_rounded,
size: 17.sp,
color: ColorConstants.warning,
),
SizedBox(width: 9.w),
Expanded(
child: Text(
notes,
style: TextStyle(
fontSize: 13.sp,
height: 1.4,
fontWeight: FontWeight.w700,
color: ColorConstants.onErrorContainer,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
Widget _actions(
BuildContext context,
String phone,
double lat,
double lng,
bool hasLocation,
) {
return Row(
children: [
if (phone.isNotEmpty) ...[
Expanded(
child: MilerButton(
label: 'Call',
icon: Icons.call_rounded,
variant: MilerButtonVariant.outlined,
color: ColorConstants.acceptGreen,
onPressed: () => _dial(phone),
),
),
SizedBox(width: 10.w),
],
Expanded(
flex: 2,
child: MilerButton(
label: 'Navigate',
icon: Icons.navigation_rounded,
color: ColorConstants.primary,
onPressed: hasLocation ? () => _navigate(lat, lng) : null,
),
),
],
);
}
Future<void> _dial(String phone) async {
final clean = phone.replaceAll(RegExp(r'[^\d+]'), '');
if (clean.isEmpty) return;
try {
await launchUrl(
Uri(scheme: 'tel', path: clean),
mode: LaunchMode.platformDefault,
);
} catch (_) {}
}
Future<void> _navigate(double lat, double lng) async {
// Native intent first, web as the fallback — same order as the stop screen.
try {
final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d');
if (await launchUrl(native, mode: LaunchMode.externalApplication)) return;
} catch (_) {}
try {
await launchUrl(
Uri.parse(
'https://www.google.com/maps/dir/?api=1'
'&destination=$lat,$lng&travelmode=driving',
),
mode: LaunchMode.externalApplication,
);
} catch (_) {}
}
}

View File

@@ -0,0 +1,752 @@
import 'dart:math' as math;
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A TRIP — one slot's worth of work, as a single unit.
///
/// The hub manager does not assign bookings one at a time. He takes every
/// customer booking that falls inside a slot (say 10:00–13:00), orders them
/// into a route, and hands the whole thing to one miler. So the rider's
/// decision is never "do I want this one parcel?" — it is "do I take this
/// trip?". Home used to show loose bookings, which framed the job as a
/// marketplace it isn't, and hid the two facts that actually matter: how long
/// the whole run takes, and where it starts and ends.
///
/// A trip is therefore always shaped:
///
/// HUB → Stop 1 → Stop 2 → … → Stop N → HUB
///
/// with the same hub at both ends. Every duration and distance below includes
/// the return leg, because the rider is not finished until he is back.
///
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
/// and safe to call from `build`.
/// ─────────────────────────────────────────────────────────────────────────
class Trip {
/// Stable identity for this trip (backend slot/trip id, or a derived key).
final String id;
/// The customer-facing slot window this trip serves. Null when the backend
/// sends no usable times — the trip is then simply "today's route".
final DateTime? slotStart;
final DateTime? slotEnd;
/// Stops in the admin's fixed order. Never re-sorted by the app.
final List<Map<String, dynamic>> stops;
/// Straight-line round-trip length in metres, hub → stops → hub.
final double routeMeters;
/// Sum of the per-stop service estimates (time off the bike).
final Duration serviceDuration;
/// Time on the bike, at [RouteMetricsHelper.kDefaultSpeedMps].
final Duration travelDuration;
final int totalParcels;
final int deliverParcels;
final int collectParcels;
final double totalWeightKg;
final double cashToCollect;
const Trip({
required this.id,
required this.slotStart,
required this.slotEnd,
required this.stops,
required this.routeMeters,
required this.serviceDuration,
required this.travelDuration,
required this.totalParcels,
required this.deliverParcels,
required this.collectParcels,
required this.totalWeightKg,
required this.cashToCollect,
});
int get stopCount => stops.length;
/// The number the rider is actually planning around: ride time plus every
/// minute spent standing at a door.
Duration get totalDuration => travelDuration + serviceDuration;
/// Average time per stop across the whole trip, service only.
Duration get averageStopDuration => stopCount == 0
? Duration.zero
: Duration(seconds: serviceDuration.inSeconds ~/ stopCount);
/// `10:00 AM – 1:00 PM`, or a plain label when the slot is unknown.
String get slotLabel {
final s = slotStart;
final e = slotEnd;
if (s == null || e == null) return "Today's route";
return '${RouteMetricsHelper.formatClock(s)} – '
'${RouteMetricsHelper.formatClock(e)}';
}
/// True when the slot window has already closed.
bool isExpired([DateTime? now]) {
final e = slotEnd;
if (e == null) return false;
return (now ?? DateTime.now()).isAfter(e);
}
/// Order ids in this trip.
List<String> get orderIds => [
for (final s in stops)
if ((s['orderid'] ?? '').toString().isNotEmpty) (s['orderid']).toString(),
];
// ── Per-stop state & completion ────────────────────────────────────────
/// State of every stop, in route order.
List<StopState> states({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds),
];
/// Ids an "accept whole trip" action should operate on — only the stops
/// still awaiting a decision. Re-accepting one already accepted would be a
/// wasted call, and re-accepting a rejected one would undo the rider.
List<String> pendingOrderIds({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
if (stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds) ==
StopState.pending)
(s['orderid'] ?? '').toString(),
].where((id) => id.isNotEmpty).toList();
/// Fraction of the trip that is finished, 0.0 → 1.0.
///
/// Counts **resolved** stops — done or rejected — not just completed ones.
/// A rejected stop is off the rider's plate; leaving it out of the numerator
/// would strand the trip below 100% with nothing he could do about it.
double completion({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) {
if (stops.isEmpty) return 0;
final resolved = states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).where((s) => s.isResolved).length;
return resolved / stops.length;
}
/// Completion as a whole percent, for display.
int completionPercent({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => (completion(acceptedIds: acceptedIds, rejectedIds: rejectedIds) * 100)
.round();
/// True once every stop has been resolved — the rider can head back.
bool isFinished({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) =>
stops.isNotEmpty &&
states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).every((s) => s.isResolved);
/// True while any stop still needs an accept/reject decision.
bool hasPendingStops({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).any((s) => s.needsDecision);
/// Short tab label — `Trip 1`.
static String tabLabel(int index) => 'Trip ${index + 1}';
/// Address components every stop on this trip shares — city, state,
/// pincode and so on. Cached per build by the caller.
List<String> get sharedAddressTail => commonAddressTail([
for (final s in stops) (s['pickupaddress'] ?? '').toString(),
]);
/// The distinguishing part of a stop's address, with the shared tail removed.
String shortAddress(Map<String, dynamic> stop, List<String> tail) =>
stripAddressTail((stop['pickupaddress'] ?? '').toString(), tail);
// ── Per-stop estimates ─────────────────────────────────────────────────
/// Estimated time OFF the bike at [index].
///
/// Service time is not a constant. Handing a parcel over and reading back an
/// OTP is quick; weighing and photographing a collection is slower; doing
/// both at one door is slower still; and counting cash adds real minutes.
/// These are the numbers the pace warning on Home is measured against, so
/// they are deliberately not optimistic.
static Duration serviceTimeFor(Map<String, dynamic> stop) {
final kind = stopKindOf(stop);
var seconds = switch (kind) {
StopKind.delivery => 150, // 2m30 — hand over, OTP, photo
StopKind.pickup => 240, // 4m00 — collect, weigh, photo
StopKind.combined => 330, // 5m30 — both, but one walk-up
};
// Cash handling is the single biggest variable at a door.
if (stopCollectionAmount(stop) > 0) seconds += 90;
// Multi-parcel stops take longer to count and load. First parcel is in
// the base estimate; each extra adds handling time.
final parcels = deliveryParcelCount(stop) + pickupParcelCount(stop);
if (parcels > 1) seconds += (parcels - 1) * 25;
return Duration(seconds: seconds);
}
/// 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}) {
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]);
if (from == null || to == null) return Duration.zero;
final meters = RouteMetricsHelper.distanceMeters(
from.lat,
from.lng,
to.lat,
to.lng,
);
return RouteMetricsHelper.travelTime(meters);
}
/// Running clock estimate of when the rider reaches stop [index], assuming
/// he leaves the hub at [departure].
DateTime etaForStop(
int index, {
required DateTime departure,
double? hubLat,
double? hubLng,
}) {
var clock = departure;
for (var i = 0; i <= index && i < stops.length; i++) {
clock = clock.add(travelTimeToStop(i, hubLat: hubLat, hubLng: hubLng));
if (i < index) clock = clock.add(serviceTimeFor(stops[i]));
}
return clock;
}
({double lat, double lng})? _origin(double? lat, double? lng) =>
(lat != null && lng != null && lat != 0 && lng != 0)
? (lat: lat, lng: lng)
: null;
// ── Construction ───────────────────────────────────────────────────────
/// Builds one trip from an ordered list of stops.
///
/// [hubLat]/[hubLng] anchor both ends of the round trip. The backend does
/// not yet send hub coordinates, so callers pass the rider's current
/// position — he is at (or near) the hub when he picks up a trip, which
/// makes it a workable stand-in. When it is missing, the route length is the
/// stop-to-stop chain only and the return leg is simply absent rather than
/// guessed.
factory Trip.fromStops({
required String id,
required List<Map<String, dynamic>> stops,
DateTime? slotStart,
DateTime? slotEnd,
double? hubLat,
double? hubLng,
}) {
final ordered = sortStops(stops);
int parcels = 0;
int deliver = 0;
int collect = 0;
double weight = 0;
double cash = 0;
var service = Duration.zero;
for (final s in ordered) {
final d = deliveryParcelCount(s);
final c = pickupParcelCount(s);
deliver += d;
collect += c;
parcels += (d + c) > 0 ? (d + c) : 1;
weight += _toDouble(s['weight'] ?? s['Weight']);
cash += stopCollectionAmount(s);
service += serviceTimeFor(s);
}
// Round trip: hub → every stop in order → back to the hub.
final chain = <({double lat, double lng})>[];
final origin =
(hubLat != null && hubLng != null && hubLat != 0 && hubLng != 0)
? (lat: hubLat, lng: hubLng)
: null;
if (origin != null) chain.add(origin);
for (final s in ordered) {
final c = _coordsOf(s);
if (c != null) chain.add(c);
}
if (origin != null && chain.length > 1) chain.add(origin);
double meters = 0;
for (var i = 0; i < chain.length - 1; i++) {
meters += RouteMetricsHelper.distanceMeters(
chain[i].lat,
chain[i].lng,
chain[i + 1].lat,
chain[i + 1].lng,
);
}
if (meters == 0) {
// Fall back to whatever per-stop kilometres the backend sent.
for (final s in ordered) {
meters += _toDouble(s['kms'] ?? s['km']) * 1000;
}
}
return Trip(
id: id,
slotStart: slotStart,
slotEnd: slotEnd,
stops: ordered,
routeMeters: meters,
serviceDuration: service,
travelDuration: RouteMetricsHelper.travelTime(meters),
totalParcels: parcels,
deliverParcels: deliver,
collectParcels: collect,
totalWeightKg: weight,
cashToCollect: cash,
);
}
/// Groups loose stops into trips.
///
/// Grouping key, in order of trust:
/// 1. An explicit backend `tripid` / `slotid` / `routeid`.
/// 2. An explicit slot window (`slotstarttime` + `slotendtime`).
/// 3. The `expected_pickup_time` bucketed into a [windowHours] block
/// aligned to the clock — 10:00–13:00, 13:00–16:00, and so on. This is
/// how the hub builds slots, so bucketing reproduces them closely enough
/// to be useful while the backend has no trip field.
/// 4. Everything else lands in one "today's route" trip rather than being
/// dropped.
///
/// Returned trips are ordered by slot start, earliest first.
/// The most trips a rider's day can contain.
///
/// Three is the operational fact, not a display limit: the hub assigns morning,
/// afternoon and evening slots. [TripTabs] renders three slots for the same
/// reason, and [_capToMaxTrips] folds any extra buckets into the last so the
/// two never disagree.
static const int maxTripsPerDay = 3;
static List<Trip> groupIntoTrips(
List<Map<String, dynamic>> stops, {
int windowHours = 3,
double? hubLat,
double? hubLng,
DateTime? now,
}) {
if (stops.isEmpty) return const [];
final clock = now ?? DateTime.now();
final buckets = <String, List<Map<String, dynamic>>>{};
final windows = <String, (DateTime?, DateTime?)>{};
for (final stop in stops) {
final explicitId = _firstNonEmpty(stop, [
'tripid',
'tripId',
'slotid',
'slotId',
'routeid',
'routeId',
]);
final slotFrom = _parseTimestamp(
stop['slotstarttime'] ?? stop['slotStartTime'] ?? stop['slotfrom'],
);
final slotTo = _parseTimestamp(
stop['slotendtime'] ?? stop['slotEndTime'] ?? stop['slotto'],
);
String key;
DateTime? from;
DateTime? to;
if (explicitId != null) {
key = 'id:$explicitId';
from = slotFrom;
to = slotTo;
} else if (slotFrom != null && slotTo != null) {
key = 'slot:${slotFrom.toIso8601String()}';
from = slotFrom;
to = slotTo;
} else {
final due = _parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
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()}';
} else {
from = null;
to = null;
key = 'day:${clock.year}-${clock.month}-${clock.day}';
}
}
buckets.putIfAbsent(key, () => []).add(stop);
windows.putIfAbsent(key, () => (from, to));
}
final trips = <Trip>[
for (final entry in buckets.entries)
Trip.fromStops(
id: entry.key,
stops: entry.value,
slotStart: windows[entry.key]?.$1,
slotEnd: windows[entry.key]?.$2,
hubLat: hubLat,
hubLng: hubLng,
),
];
trips.sort((a, b) {
final as = a.slotStart;
final bs = b.slotStart;
if (as == null && bs == null) return 0;
if (as == null) return 1; // undated trips last
if (bs == null) return -1;
return as.compareTo(bs);
});
return _capToMaxTrips(trips, hubLat: hubLat, hubLng: hubLng);
}
/// A rider's day is three trips. Anything past that is folded into the last.
///
/// The bucketing above is time-based — a stop with no trip id lands in a
/// three-hour window — so a long day, or one stop booked well outside the
/// others, can produce a fourth or fifth bucket. That surfaced as a "Trip 4"
/// tab, which is not a thing the hub assigns.
///
/// **Merged, never dropped.** Truncating the list would be the one-line fix
/// and it would silently hide real work: those stops are accepted bookings the
/// rider is expected to complete, and a stop he cannot see is a stop he cannot
/// deliver. The tail is therefore folded into the third trip, whose slot then
/// spans from its own start to the last stop's end, so the label stays honest
/// about what it now contains.
static List<Trip> _capToMaxTrips(
List<Trip> trips, {
double? hubLat,
double? hubLng,
}) {
if (trips.length <= maxTripsPerDay) return trips;
final kept = trips.take(maxTripsPerDay - 1).toList();
final tail = trips.skip(maxTripsPerDay - 1).toList();
final mergedStops = [for (final t in tail) ...t.stops];
final starts = tail.map((t) => t.slotStart).whereType<DateTime>();
final ends = tail.map((t) => t.slotEnd).whereType<DateTime>();
kept.add(
Trip.fromStops(
id: tail.first.id,
stops: mergedStops,
slotStart: starts.isEmpty
? null
: starts.reduce((a, b) => a.isBefore(b) ? a : b),
slotEnd: ends.isEmpty
? null
: ends.reduce((a, b) => a.isAfter(b) ? a : b),
hubLat: hubLat,
hubLng: hubLng,
),
);
return kept;
}
/// 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.
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]),
];
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];
}
// ── helpers ────────────────────────────────────────────────────────────
static ({double lat, double lng})? _coordsOf(Map<String, dynamic> stop) {
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (lat == 0 || lng == 0) return null;
return (lat: lat, lng: lng);
}
static String? _firstNonEmpty(Map<String, dynamic> m, List<String> keys) {
for (final k in keys) {
final v = m[k];
final s = v?.toString().trim() ?? '';
if (s.isNotEmpty && s != '0' && s != 'null') return s;
}
return null;
}
static DateTime? _parseTimestamp(dynamic v) {
if (v == null) return null;
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();
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
}
/// ─────────────────────────────────────────────────────────────────────────
/// ADDRESS COMPRESSION
///
/// A raw stop address is written for a postal system, not a rider:
///
/// "5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041"
///
/// On a six-stop trip, "Coimbatore, Tamil Nadu, 641041" is repeated six times
/// and distinguishes nothing — every stop is in the same city, or the hub
/// would not have put them on one route. It is pure noise, and it is the noise
/// that pushes the useful part ("5/2, North Avenue") onto a second line and
/// then off the end of an ellipsis.
///
/// So: find the trailing components EVERY stop on the trip shares, and drop
/// them. What survives is exactly the part that tells one stop from another.
/// The full address is never lost — it goes to the detail sheet and to the
/// navigation hand-off untouched.
///
/// This is data-driven rather than a hardcoded list of cities and states: it
/// works for any city, any country, and degrades to a no-op on a one-stop trip
/// (where nothing is shared, so nothing is dropped).
/// ─────────────────────────────────────────────────────────────────────────
/// Splits an address into trimmed, non-empty components.
List<String> addressParts(String address) =>
address.split(',').map((p) => p.trim()).where((p) => p.isNotEmpty).toList();
/// The trailing components shared by every address in [addresses].
///
/// Returns an empty list when fewer than two addresses are given, or when the
/// tails differ — there is then nothing redundant to remove.
List<String> commonAddressTail(List<String> addresses) {
final parts = [
for (final a in addresses)
if (addressParts(a).isNotEmpty) addressParts(a),
];
if (parts.length < 2) return const [];
final shortest = parts.map((p) => p.length).reduce((a, b) => a < b ? a : b);
final tail = <String>[];
for (var back = 1; back < shortest; back++) {
final candidate = parts.first[parts.first.length - back].toLowerCase();
final shared = parts.every(
(p) => p[p.length - back].toLowerCase() == candidate,
);
if (!shared) break;
tail.insert(0, parts.first[parts.first.length - back]);
}
return tail;
}
/// [address] with [tail] removed. Always leaves at least one component, so a
/// stop can never render a blank address.
String stripAddressTail(String address, List<String> tail) {
if (tail.isEmpty) return address.trim();
final parts = addressParts(address);
var end = parts.length;
for (var i = tail.length - 1; i >= 0; i--) {
if (end <= 1) break;
if (parts[end - 1].toLowerCase() != tail[i].toLowerCase()) break;
end--;
}
return parts.sublist(0, end).join(', ');
}
/// Where a single stop sits in the accept → work → done lifecycle.
///
/// This is what lets one trip card serve both Home and Bookings. A trip does
/// not leave Home the moment it is accepted — the rider still wants to watch
/// it fill up — so every stop carries its own state and the card renders the
/// right control for it: Accept/Reject while pending, a progress tick once
/// it's moving, nothing at all once it's done.
enum StopState {
/// Awaiting the rider's decision. Shows Accept + Reject.
pending,
/// Accepted, not yet started. Lives on the Bookings tab.
accepted,
/// The rider is physically on this stop right now.
active,
/// Picked up / delivered.
done,
/// Skipped, awaiting a return visit. Not a failure.
skipped,
/// The rider declined this stop. It stays visible, struck through, so he
/// can see he handled it rather than wondering where it went.
rejected,
}
extension StopStateX on StopState {
/// Counts toward "trip complete". A rejected stop is resolved — the rider
/// has nothing left to do with it — so it must not hold the trip at 90%
/// forever.
bool get isResolved => this == StopState.done || this == StopState.rejected;
/// Still needs a decision from the rider.
bool get needsDecision => this == StopState.pending;
/// Committed to: he has taken it and owes the customer a visit.
bool get isCommitted =>
this == StopState.accepted ||
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',
};
}
/// Derives a stop's state from its backend status plus the local
/// accepted/rejected stores.
///
/// Local stores win for accept/reject because those writes are optimistic —
/// the rider's tap is recorded instantly and the network catches up. If the
/// server view won, a slow response would bounce a stop back to "pending"
/// under his thumb.
StopState stopStateOf(
Map<String, dynamic> stop, {
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) {
final id = (stop['orderid'] ?? '').toString();
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
// 1. Irreversible server states win over everything. A parcel that has been
// collected or handed over cannot be un-decided by a local tap.
if (raw == 'picked' ||
raw == 'picked up' ||
raw == 'pickedup' ||
raw == 'pickuped' ||
raw == 'delivered' ||
raw == 'cancelled' ||
raw == 'canceled') {
return StopState.done;
}
// 2. The rider is physically on the stop — also not a local decision.
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.
// 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.
if (raw == 'rejected') return StopState.rejected;
if (raw == 'accepted') return StopState.accepted;
return StopState.pending;
}
/// How a stop is progressing through the trip, for the progress rail.
enum StopProgress {
/// Finished — picked up / delivered. Rail turns green here.
done,
/// The stop the rider is on right now.
current,
/// Not started.
pending,
/// Skipped and awaiting a return visit.
skipped,
}
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
String formatTripDuration(Duration d) {
if (d.inMinutes < 1) return '—';
if (d.inMinutes < 60) return '${d.inMinutes} min';
final minutes = d.inMinutes.remainder(60);
return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m';
}
/// Rounds a duration up to the nearest 5 minutes.
///
/// An estimate printed as "37 min" claims a precision the arithmetic does not
/// have and invites the rider to treat it as a promise. "40 min" reads as
/// what it is.
Duration roundTripDuration(Duration d) {
if (d.inSeconds <= 0) return Duration.zero;
final minutes = (d.inMinutes / 5).ceil() * 5;
return Duration(minutes: math.max(5, minutes));
}

View File

@@ -0,0 +1,590 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/home/route_brief.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';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE TRIP BRIEF — one strip, opened only when the rider asks for it
///
/// ── What this replaced, and what it cost ──
///
/// The top of Home carried four separate surfaces before the first stop: a
/// `ShiftBanner` strip, a large white "CURRENT SHIFT" card holding a 25sp slot
/// window, a 66dp completion ring, a parcel chip and VIEW ROUTE, and then a row
/// of three shadowed stat cards for Duration, Distance and Payment.
///
/// Measured at the 390×844 design size, that stack was **~351dp**: roughly
/// 49 for the banner, 168 for the shift card, 114 for the stat cards, plus the
/// gaps. On the same device the scrollable region under the app bar is about
/// 650dp, and the pinned trip tabs take ~56 of it. So **over half of the
/// scrollable screen was spent before the rider saw a single stop**, and on a
/// six-stop route he could see two of them.
///
/// ── Why that was the wrong trade, not just a big one ──
///
/// Every one of those figures is **start-of-shift briefing material**. The slot
/// window, the round-trip duration, the route distance, the parcel count and
/// the cash total are all fixed the moment the hub assigns the trip; none of
/// them changes while the rider works. They are read once, at the start, and
/// then they sit there for the rest of the shift charging full price.
///
/// Only two things on that whole stack move during a shift: **how far through
/// he is**, and **how much of the shift is left**. Those are the two the rider
/// glances at repeatedly, and they were the two buried among six that he wasn't
/// looking at. So they stay on the face of the strip and everything else goes
/// one tap away — the same trade Uber Driver makes with its earnings header and
/// Google Maps makes with the collapsed navigation sheet: one live line, the
/// full breakdown on demand.
///
/// ── Two things this also fixes ──
///
/// • **The old card was mislabelled.** It printed the caption "CURRENT SHIFT"
/// above `trip.slotLabel`, which is the *trip's* window (09:00–13:00), not
/// the rider's shift (09:00–18:00). The real shift window was in the
/// `ShiftBanner` directly above it. Two different time ranges, stacked, one
/// of them wearing the other's name. Expanded, they are now adjacent rows
/// with their own labels, so the difference is finally legible.
///
/// • **Progress was drawn twice.** A 66dp animated ring here, and "50% done"
/// on the pinned tab ~56dp below it. The ring is now 34dp and carries the
/// fraction as text beside it, because the tab already says the percentage.
///
/// ── What is deliberately NOT hidden ──
///
/// Collapsing must never hide a problem. Two things therefore stay on the
/// collapsed face:
///
/// • **Time left**, coloured by [RouteBrief.isPaceAtRisk] and by whether the
/// window has closed. If the rider cannot finish in time, the strip says so
/// without being opened.
/// • **Cash to collect**, whenever there is any. Miler is the carrier, so that
/// money is the rider's personal liability until he reconciles it at the
/// hub — how much he is carrying is not reference material.
/// ─────────────────────────────────────────────────────────────────────────
class TripBriefStrip extends StatefulWidget {
final Trip trip;
final RouteBrief brief;
/// Completion across the trip, 0–100. Supplied by the page because it is
/// computed from the accepted/rejected sets the page owns.
final int percent;
/// Opens the whole route on one map. Hidden when the trip has no stops.
final VoidCallback? onViewRoute;
const TripBriefStrip({
super.key,
required this.trip,
required this.brief,
required this.percent,
this.onViewRoute,
});
@override
State<TripBriefStrip> createState() => _TripBriefStripState();
}
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;
/// 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();
@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),
),
],
),
// 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()],
),
),
),
);
}
// ── 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;
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(
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',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
_timeLeftLine(),
],
),
),
if (cash > 0) ...[SizedBox(width: 8.w), _cashChip(cash)],
SizedBox(width: 2.w),
AnimatedRotation(
turns: _open ? 0.5 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
child: Icon(
Icons.keyboard_arrow_down_rounded,
size: 24.sp,
color: ColorConstants.secondaryText,
),
),
],
),
),
),
),
);
}
/// The one line on this widget that can carry bad news, so it is the one line
/// that is allowed to change colour.
Widget _timeLeftLine() {
final left = _brief.timeLeft;
final String text;
final Color color;
if (!_brief.shiftSet || left == null) {
// 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.
text = _trip.slotLabel;
color = ColorConstants.secondaryText;
} else if (left == Duration.zero) {
text = 'Shift ended';
color = ColorConstants.error;
} else if (_brief.isPaceAtRisk) {
// Under six minutes a stop — faster than a pickup can physically be
// done. He needs to see this without opening anything.
text = '${formatDuration(left)} left · running tight';
color = ColorConstants.warning;
} else {
text = '${formatDuration(left)} left in shift';
color = ColorConstants.secondaryText;
}
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: color,
fontFamily: FontConstants.fontFamily,
),
);
}
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,
),
),
);
}
// ── The expanded brief ───────────────────────────────────────────────────
//
// ── Why there are no cards in here ──
//
// Each figure used to sit in its own bordered, tinted box. That is four more
// surfaces inside a surface that is already inside a page — and it repeated
// the exact mistake the old permanent header made, just one tap further in:
// a box around a number adds no information, the number IS the information.
// Worse, a boxed figure has to be *aligned* somewhere inside its box, and
// left-aligning it parked every value in the top-left corner of an otherwise
// empty rectangle, so the band read as four mostly-blank cards rather than
// four facts.
//
// So: no boxes, and the figures are centred on their own column. One white
// surface, two bands of content, and the whitespace between them doing the
// grouping that borders were doing badly.
//
// 1. **The shift window**, as a full-width label→value row, and only when
// the hub has actually set one. The trip slot was a second row here until
// it turned out to be the same range the collapsed face already shows —
// see the note at that band.
//
// 2. **The four journey figures**, centred, evenly across one row.
//
// VIEW ROUTE is not here any more either; it is an action, and it now sits on
// the START · HUB row where the route actually begins. See `_hubRow`.
Widget _body() {
final cash = _trip.cashToCollect;
final duration = roundTripDuration(_trip.totalDuration);
return Padding(
padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Divider(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 10.h),
// ── Band 1: the shift window ──
//
// The trip slot used to sit here as a second row. It was printing the
// same range the face prints two lines above it — [_timeLeftLine]
// falls back to `slotLabel` whenever the hub has not set a shift — so
// opening the strip showed the rider the identical window twice, once
// under "2 of 6 stops done" and once under "Trip slot". The slot is
// also on the trip tab right above the strip, which is where it
// belongs: it is what distinguishes one trip tab from another.
//
// So the row is gone, and this band now exists only when there is a
// shift window to state — a window the face reports as time *left*
// rather than as a range.
if (_brief.shiftSet) ...[
_windowRow(
icon: Icons.event_available_rounded,
label: 'Shift',
value: _brief.shiftWindowLabel,
tint: ColorConstants.slateText,
),
SizedBox(height: 14.h),
Divider(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 14.h),
],
// ── Band 2: the journey, four centred figures on one row ──
//
// `IntrinsicHeight` so the four columns share a height and their
// captions land on one baseline even when a value scales down.
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _figure(
icon: Icons.schedule_rounded,
value: '≈${formatTripDuration(duration)}',
label: 'DURATION',
),
),
Expanded(
child: _figure(
icon: Icons.route_rounded,
value: RouteMetricsHelper.formatDistance(_trip.routeMeters),
label: 'DISTANCE',
),
),
Expanded(
child: _figure(
icon: Icons.inventory_2_rounded,
value: '${_trip.totalParcels}',
label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS',
),
),
Expanded(
child: cash > 0
? _figure(
icon: Icons.payments_rounded,
value: '₹${_money(cash)}',
label: 'PAYMENT',
money: true,
)
// Holds its column so a cash-free trip keeps the row on
// four even columns instead of redistributing the space
// and shifting the other three figures sideways.
: _figure(
icon: Icons.money_off_rounded,
value: 'None',
label: 'PAYMENT',
muted: true,
),
),
],
),
),
],
),
);
}
/// One time window: icon disc, label, and the range itself on the right.
Widget _windowRow({
required IconData icon,
required String label,
required String value,
required Color tint,
}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 5.h),
child: Row(
children: [
Container(
width: 30.w,
height: 30.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(icon, size: 16.sp, color: tint),
),
SizedBox(width: 10.w),
Text(
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
const Spacer(),
Flexible(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
/// One journey figure, centred on its column and standing on the page rather
/// than inside a box: icon, the number, a small-caps caption.
///
/// The icon is bare, with no tinted disc behind it. At four across, a disc per
/// figure put four filled circles in a row that read as the loudest thing in
/// the brief while carrying none of its information — and the caption under
/// each value already says what the icon says.
Widget _figure({
required IconData icon,
required String value,
required String label,
bool money = false,
bool muted = false,
}) {
// Only the money is coloured. The rest are facts about the journey, and
// tinting every category is the habit this screen has spent a long time
// unlearning.
final Color accent = money
? ColorConstants.moneyGreen
: muted
? ColorConstants.secondaryText
: ColorConstants.slateText;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 3.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 17.sp,
color: money
? ColorConstants.moneyGreen
: ColorConstants.secondaryText,
),
SizedBox(height: 7.h),
// Fixed height with a scale-down inside it, so a long value shrinks
// rather than making one of the four columns taller than the rest.
SizedBox(
height: 21.h,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Text(
value,
style: TextStyle(
fontSize: 16.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.05,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(height: 4.h),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 9.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
/// `1234.0` → `1,234`, `1234.5` → `1,234.5`. Mirrors the trim the stat card
/// used so the figure does not change shape as it moves into the strip.
static String _money(double v) {
final whole = v == v.roundToDouble();
final s = whole ? v.toStringAsFixed(0) : v.toStringAsFixed(1);
final parts = s.split('.');
final digits = parts.first;
final buf = StringBuffer();
for (int i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
buf.write(digits[i]);
}
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

@@ -0,0 +1,264 @@
import 'package:flutter/material.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';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP NOT ASSIGNED YET
///
/// The state a trip tab sits in before the hub fills it. Deliberately not the
/// generic "no bookings" screen: the rider's question on Trip 3 at 10am is
/// "will I get a third trip?", and the honest answer is "not yet" — not "you
/// have no work", which is false while Trip 1 is running.
///
/// **No card.** This used to be a bordered white box floating on the page,
/// which read as a piece of *content* the hub had sent — a card that had
/// arrived with nothing in it. An empty slot is the absence of content, so it
/// is drawn as the page itself: no border, no fill, no shadow, the same
/// boxless treatment every other empty screen in the app uses through
/// [MilerEmptyState].
///
/// **It breathes.** Waiting is the whole subject of the screen, so the halo
/// swells and settles on a slow four-second cycle and the chip's dot pulses
/// with it. A still screen saying "waiting" is a claim the interface does not
/// back up; a rider glancing down needs to see that the app is still watching
/// for his trip and has not simply stopped. Off duty is not waiting — nothing
/// is coming until he goes on — so there the motion stops and the colour
/// drains to grey.
/// ─────────────────────────────────────────────────────────────────────────
class TripEmptyState extends StatefulWidget {
/// `Trip 2` — the tab this slot belongs to, named the same way the tabs,
/// the hub and his supervisor name it.
final String tripLabel;
/// Off duty. A different sentence, a different colour, and the one action
/// that changes it.
final bool offline;
/// Opens the duty confirmation. Only used when [offline].
final VoidCallback? onGoOnDuty;
const TripEmptyState({
super.key,
required this.tripLabel,
required this.offline,
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.
final Color accent = offline
? ColorConstants.secondaryText
: ColorConstants.primary;
// The button is the exception, and deliberately: a grey "Go on duty" on a
// grey screen reads as a disabled control, which is the opposite of what it
// is — the one live thing on an off-duty screen. Green is what going on
// duty is coloured everywhere else in the app.
final Color cta = ColorConstants.acceptGreen;
return Padding(
// Generous top air. With the card gone there is no edge to separate this
// from the pinned tab bar, so the space does that job — and an empty
// screen is the one place in the app that can afford it.
//
// Vertical only: [MilerEmptyState] already insets its copy by 40 a side,
// 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',
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.',
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),
),
);
}
}
/// The icon on two soft rings that swell and settle together.
class _BreathingHalo extends StatelessWidget {
final Animation<double> breath;
final Color accent;
final IconData icon;
final bool animate;
const _BreathingHalo({
required this.breath,
required this.accent,
required this.icon,
required this.animate,
});
@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,
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,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP PROGRESS — how far through the route the rider is.
///
/// Shown at the top of Bookings, above the stop list:
///
/// 10:00 AM – 1:00 PM 4 of 18 done
/// ████████▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
/// On stop 5 of 18 · 2 skipped · 2h 10m left, then hub
///
/// **No box.** This was a white card — 1.5px border, 16px radius, a drop
/// shadow, 12px of padding on all four sides and a 12px top margin — sitting
/// directly above a list of white bordered stop cards. It cost roughly 40px of
/// height for chrome alone, on the first screen of the page, and the border it
/// spent that height on was the same border the cards below already use, so it
/// read as a stop card that could not be tapped.
///
/// A progress bar does not need a container to be legible: it is already a
/// shape with a hard edge. So the box, its shadow, its padding and the icon on
/// the title row are gone, and what is left sits straight on the page in the
/// same 16px gutter as the cards. Same three facts, about half the height, and
/// the first real stop is now visible without scrolling.
///
/// ── Numbered nodes, not a proportional bar ──
///
/// A five-stop route draws 1 2 3 4 5, and two stops done means two of those
/// numbers are filled green. The bar that was here before was honest about the
/// *fraction* — 40% of its width green — but a fraction is not the unit the
/// rider works in. He is on stop 3; the list below numbers its cards 1…5; the
/// map pins are numbered. The summary now speaks the same language as
/// everything it summarises, and tapping a number is a way back to that stop.
///
/// The scooter rides the rail: a `two_wheeler` glyph sits above the node the
/// rider is working, so where-am-I is one glance rather than a count — and when
/// a stop is completed it **drives forward to the next one** while the node it
/// left turns green. Progress on this page is something the rider watches
/// happen, not something he re-reads. Colour does the rest:
///
/// • **Green** for completed. Green is the app's only "done" signal.
/// • **Amber** for skipped: a skipped stop is neither done nor waiting, and it
/// has to stay visible as unfinished business.
/// • **Maroon** for the stop he is on, under the scooter.
/// • **Grey** for everything still ahead.
///
/// ── The reason numbers were dropped once before, and what changed ──
///
/// This widget used to draw a node per stop inside a fixed-width row, so route
/// length — set by the hub, bounded by nothing here — squeezed the nodes into
/// each other until the digits were unreadable and the hub markers collided. It
/// got worse exactly as the rider's day got harder, which is why it became a
/// bar.
///
/// The nodes are back because that failure was a layout bug, not a problem with
/// numbering: the row was made to fit an unbounded count into a fixed width.
/// Now the nodes have a **fixed size and the row scrolls** when they no longer
/// fit, and it auto-scrolls to keep the scooter in view. A 5-stop route lays
/// out evenly across the full width; a 40-stop route draws the same 34pt nodes
/// and lets the rider push the rest into view. The widget's height is constant
/// either way — see `trip_progress_test.dart`, which is what stops this from
/// regressing into a crush again.
/// ─────────────────────────────────────────────────────────────────────────
class TripProgressRail extends StatefulWidget {
/// Progress state per stop, in the admin's fixed order.
final List<StopProgress> progress;
/// Slot window label, e.g. `10:00 AM – 1:00 PM`.
final String? slotLabel;
/// Remaining time estimate for the unfinished part of the trip.
final Duration? remaining;
/// Tapping a node scrolls to / opens that stop.
final void Function(int index)? onStopTap;
const TripProgressRail({
super.key,
required this.progress,
this.slotLabel,
this.remaining,
this.onStopTap,
});
@override
State<TripProgressRail> createState() => _TripProgressRailState();
}
class _TripProgressRailState extends State<TripProgressRail> {
/// Only attached when the rail actually overflows; a short route lays out
/// with no scroll view at all.
final ScrollController _scroll = ScrollController();
/// Node footprint, and the tightest the gap between two nodes is allowed to
/// get. 34 rather than the 26 this started at: the numbers are the rail's
/// content, and at 26 a two-digit stop filled its circle wall to wall.
static const double _node = 34;
static const double _minGap = 22;
/// The scooter's box, and the air between it and the nodes it rides over.
static const double _scooter = 24;
static const double _lift = 3;
/// Constant, by construction — nodes never shrink and the scooter always
/// rides above them, so route length changes the rail's *width* only.
double get _railHeight => _scooter.w + _lift.h + _node.w;
/// How long the scooter takes to travel one stop. Long enough to read as
/// movement — the point is that the rider *sees* his progress advance when a
/// stop is completed, rather than finding the marker already somewhere else.
static const Duration _ride = Duration(milliseconds: 480);
List<StopProgress> get progress => widget.progress;
String? get slotLabel => widget.slotLabel;
Duration? get remaining => widget.remaining;
@override
void initState() {
super.initState();
_scheduleReveal();
}
@override
void didUpdateWidget(covariant TripProgressRail old) {
super.didUpdateWidget(old);
// The rider finishing a stop moves the scooter, and on a long route the
// node it moves to may be off the right edge.
if (old.progress.length != progress.length ||
_currentPosition != _positionIn(old.progress)) {
_scheduleReveal();
}
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
/// Centres the scooter's node after layout, when there is anything to scroll.
void _scheduleReveal() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scroll.hasClients) return;
final at = _currentPosition;
if (at == null) return;
final double viewport = _scroll.position.viewportDimension;
final double target =
((at - 1) * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2);
final double clamped = target.clamp(
0.0,
_scroll.position.maxScrollExtent,
);
if ((clamped - _scroll.offset).abs() < 1) return;
_scroll.animateTo(
clamped,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
});
}
int get _doneCount => progress.where((p) => p == StopProgress.done).length;
int get _skippedCount =>
progress.where((p) => p == StopProgress.skipped).length;
/// 1-based position of the stop the rider is on, or the first one still
/// waiting. Null once nothing is left.
int? get _currentPosition => _positionIn(progress);
int? _positionIn(List<StopProgress> states) {
final active = states.indexOf(StopProgress.current);
if (active >= 0) return active + 1;
final next = states.indexOf(StopProgress.pending);
return next >= 0 ? next + 1 : null;
}
/// Spacing between two node centres.
///
/// A short route spreads its nodes evenly across the whole width rather than
/// huddling on the left; once even spacing would fall below [_minGap] the gap
/// locks at that floor and the rail starts scrolling instead. Nodes never
/// shrink — that was the failure that killed the first version of this rail.
double _gapFor(double available, int total) {
if (total < 2) return 0;
final double even = (available - (total * _node.w)) / (total - 1);
return even >= _minGap.w ? even : _minGap.w;
}
/// The rail: one numbered node per stop, joined by connectors, with the
/// scooter riding above the stop the rider is working.
///
/// ── Why the scooter is an overlay and not just the current node's glyph ──
///
/// It used to *be* the node — the current circle drew a scooter instead of
/// its digit. That cost the stop its number, and worse, finishing a stop
/// simply repainted two circles: the marker was already at the next stop
/// before the rider's eye got there, so nothing appeared to happen. As a
/// separate layer it can be *positioned*, which means it can be animated, so
/// completing a stop visibly drives the scooter forward one node ([_ride])
/// and every stop keeps its number underneath.
Widget _rail() {
final total = progress.length;
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);
// +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>[];
for (var i = 0; i < total; i++) {
if (i > 0) {
// A connector is coloured by the stop *behind* it, so the green
// runs continuously from the start of the route up to the scooter
// rather than restarting at each node.
children.add(_connector(progress[i - 1], width: gap));
}
children.add(_dot(i));
}
final int? at = _currentPosition;
final Widget layers = SizedBox(
width: fits ? available : content,
height: _railHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
bottom: 0,
child: Row(mainAxisSize: MainAxisSize.min, children: children),
),
if (at != null)
AnimatedPositioned(
duration: _ride,
curve: Curves.easeOutCubic,
top: 0,
// 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),
child: _scooterGlyph(),
),
],
),
);
if (fits) return layers;
return SizedBox(
height: _railHeight,
child: SingleChildScrollView(
controller: _scroll,
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: layers,
),
);
},
);
}
/// The rider, in the app's own maroon. No disc behind it: the rail sits bare
/// on the page and a filled badge here would outweigh the node it points at.
Widget _scooterGlyph() {
return SizedBox(
width: _scooter.w,
height: _scooter.w,
child: Icon(
Icons.two_wheeler_rounded,
size: 20.sp,
color: ColorConstants.primary,
),
);
}
/// One stop, as the number the rest of the page calls it by.
///
/// Every node keeps its digit, including the one the rider is on — the
/// scooter rides above it rather than replacing it. The fill animates on
/// [_ride] so a stop turning green is a change the rider can see happen,
/// in step with the scooter leaving it.
Widget _dot(int index) {
final state = progress[index];
final Color fill;
final Color ink;
switch (state) {
case StopProgress.done:
fill = ColorConstants.acceptGreen;
ink = Colors.white;
case StopProgress.skipped:
fill = ColorConstants.warning;
ink = Colors.white;
case StopProgress.current:
fill = ColorConstants.primary;
ink = Colors.white;
case StopProgress.pending:
// Filled grey rather than an outlined circle: the rail sits bare on the
// page above bordered stop cards, and a ring per stop would put a row
// of small competing edges directly above them.
fill = ColorConstants.neutralLight;
ink = ColorConstants.secondaryText;
}
final Widget circle = AnimatedContainer(
duration: _ride,
curve: Curves.easeOutCubic,
width: _node.w,
height: _node.w,
alignment: Alignment.center,
decoration: BoxDecoration(color: fill, shape: BoxShape.circle),
child: Text(
'${index + 1}',
style: TextStyle(
// 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,
letterSpacing: -0.3,
height: 1,
color: ink,
fontFamily: FontConstants.fontFamily,
),
),
);
return Semantics(
button: widget.onStopTap != null,
label: 'Stop ${index + 1}, ${_stateWord(state)}',
child: GestureDetector(
onTap: widget.onStopTap == null ? null : () => widget.onStopTap!(index),
behavior: HitTestBehavior.opaque,
child: circle,
),
);
}
static String _stateWord(StopProgress state) => switch (state) {
StopProgress.done => 'done',
StopProgress.skipped => 'skipped',
StopProgress.current => 'you are here',
StopProgress.pending => 'not started',
};
/// The line between two nodes. Green behind a finished stop, amber behind a
/// skipped one, grey ahead — so the rail reads as a route travelled, not as a
/// set of unrelated badges.
Widget _connector(StopProgress behind, {required double width}) {
final Color color = switch (behind) {
StopProgress.done => ColorConstants.acceptGreen,
StopProgress.skipped => ColorConstants.warning,
_ => ColorConstants.borderStrong,
};
return AnimatedContainer(
duration: _ride,
curve: Curves.easeOutCubic,
width: width,
height: 4.h,
color: color,
);
}
@override
Widget build(BuildContext context) {
if (progress.isEmpty) return const SizedBox.shrink();
final total = progress.length;
final done = _doneCount;
final allDone = done == total;
return Padding(
// Same 16px gutter as the stop cards below, so the bar lines up with
// their edges instead of being inset by a border's worth again.
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Headline: slot + progress count ──
//
// The count lost its pill. A bordered chip around two words, directly
// above a bordered bar, was two edges saying one thing; colour and
// weight carry "done" here exactly as they do everywhere else in the
// app, and green already means finished.
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
slotLabel ?? 'Your route',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 8.w),
Text(
allDone ? 'All stops done' : '$done of $total done',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: allDone
? ColorConstants.acceptGreen
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
SizedBox(height: 9.h),
// ── The rail ──
//
// One numbered node per stop, the finished ones green, the scooter on
// the one he is working. Fixed node size with a horizontal scroll
// behind it, so a 40-stop route is longer, never smaller.
_rail(),
SizedBox(height: 8.h),
// ── Foot: where he is, and what is left, on one line ──
//
// The rail used to spend this line on START·Hub / RETURN·Hub labels
// at either end. Both are constant on every route, every day — the
// trip always leaves the hub and always comes back — so they were
// paying for width with no information. What changes, and what he
// actually wants, is which stop he is on and how long is left.
//
// Both halves used to sit at opposite ends of a Row, which left a gap
// in the middle wide enough to read as two separate facts. They are
// one sentence about one trip, so they are now one string, and the
// whole line ellipsises as a unit if the route label is long.
Text(
allDone
? 'Head back to the hub'
: _footStatus(total: total, done: done),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w700,
color: allDone
? ColorConstants.acceptGreen
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
String _footStatus({required int total, required int done}) {
final at = _currentPosition;
final skipped = _skippedCount;
final left = remaining;
final parts = <String>[
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',
];
return parts.join(' · ');
}
}
/// Hub accent, shared with the Home trip card.
final Color kRailHubColor = ColorConstants.tertiary;

View File

@@ -0,0 +1,707 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP TABS — Trip 1 · Trip 2 · Trip 3
///
/// A rider is assigned at most three trips a day, so three tabs fit across a
/// phone and every trip is one tap away. Tabs beat a dropdown here for a
/// reason: with a dropdown the rider cannot see how many trips he has or how
/// far along each one is without opening it, and "how much is left today" is
/// the question he asks most.
///
/// Each tab carries its own completion percentage, so the whole day reads at a
/// glance: `Trip 1 · 100% Trip 2 · 40% Trip 3 · 0%`
///
/// **Overflow.** Three is the norm, not a guarantee — a hub can assign more.
/// Past three the bar keeps the first two tabs and folds the rest into a
/// "More" menu, rather than shrinking every tab until the labels are
/// unreadable. The dropdown is the exception path, exactly as asked.
/// ─────────────────────────────────────────────────────────────────────────
class TripTabs extends StatefulWidget {
final List<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
/// Optimistic accept/reject sets, for the per-tab completion figure.
final Set<String> acceptedIds;
final Set<String> rejectedIds;
/// Tabs shown inline before the rest collapse into the overflow menu.
///
/// Also the number of tabs ALWAYS rendered: a rider's day is three trips, so
/// Trip 2 and Trip 3 are shown even before the hub has assigned them. A tab
/// that appears and disappears through the morning makes the screen feel
/// unstable; an empty Trip 3 that says "nothing assigned yet" answers the
/// question he actually has.
static const int maxInlineTabs = 3;
/// The travelling selection marker at the foot of the pill.
static const Key markerKey = ValueKey('trip_tab_marker');
const TripTabs({
super.key,
required this.trips,
required this.selectedIndex,
required this.onSelect,
this.acceptedIds = const {},
this.rejectedIds = const {},
});
@override
State<TripTabs> createState() => _TripTabsState();
}
class _TripTabsState extends State<TripTabs>
with SingleTickerProviderStateMixin {
/// Carries the pill from the tab it was on to the tab just chosen.
///
/// Same mechanism as the bottom nav bar, and for the same reason. The pill
/// used to belong to each tab — an `AnimatedContainer` per segment whose fill
/// went from transparent to white — so moving from Trip 3 to Trip 1 faded one
/// pill out on the right and a *different* one in on the left. Nothing ever
/// occupied the space between, so there was no object to travel across Trip 2
/// with, and the marker appeared to teleport.
///
/// There is one pill now, owned by the control, and this is its position in
/// *slot* units — 1.5 is genuinely halfway between the second and third
/// segment. It also drives the tint on every label, so a tab warms up as the
/// pill passes over it instead of switching the instant it is tapped while the
/// pill is still two segments away.
///
/// Initialised on the declaration rather than in `initState`: a `late final`
/// assigned in `initState` is never filled in on a `State` that already
/// exists, and hot reload reuses the existing `State`. See the identical note
/// in `Bottom_page.dart`.
late final AnimationController _pillAnim = AnimationController(
vsync: this,
// Matches the nav bar. A pill crossing two segments needs long enough for
// the eye to follow it; the 200ms the cross-fade used reads as a jump.
duration: const Duration(milliseconds: 380),
value: 1.0,
);
late final Animation<double> _pillCurve = CurvedAnimation(
parent: _pillAnim,
curve: Curves.easeInOutCubic,
);
late Tween<double> _pillTween = Tween<double>(
begin: _slotOf(_inlineTabs()),
end: _slotOf(_inlineTabs()),
);
double get _pillPos => _pillTween.evaluate(_pillCurve);
/// Trip indices rendered as inline segments, left to right.
///
/// Three is the norm, not a guarantee — a hub can assign more. Past three the
/// bar keeps the first two segments and folds the rest into a "More" menu
/// rather than shrinking every tab until the labels are unreadable. When the
/// selection lives in the overflow it is pulled into the last inline slot, so
/// the rider can always see which trip he is looking at.
List<int> _inlineTabs() {
final tabCount = widget.trips.length > TripTabs.maxInlineTabs
? widget.trips.length
: TripTabs.maxInlineTabs;
final overflows = tabCount > TripTabs.maxInlineTabs;
final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount;
return <int>[
for (var i = 0; i < inlineCount; i++) i,
if (overflows && widget.selectedIndex >= inlineCount)
widget.selectedIndex,
];
}
List<int> _hiddenTabs(List<int> inline) {
final tabCount = widget.trips.length > TripTabs.maxInlineTabs
? widget.trips.length
: TripTabs.maxInlineTabs;
final overflows = tabCount > TripTabs.maxInlineTabs;
final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount;
return <int>[
for (var i = inlineCount; i < tabCount; i++)
if (!inline.contains(i)) i,
];
}
/// Which *slot* the selection occupies. Not the same as the trip index once
/// an overflow menu is in play.
double _slotOf(List<int> inline) {
final i = inline.indexOf(widget.selectedIndex);
return (i < 0 ? 0 : i).toDouble();
}
@override
void didUpdateWidget(TripTabs old) {
super.didUpdateWidget(old);
// Selection is owned by the page, not by this control, so the slide starts
// from here rather than from a tap handler.
final target = _slotOf(_inlineTabs());
if ((target - _pillTween.end!).abs() < 0.001) return;
// Begin from where the pill actually *is*. Starting from the previous
// selection instead would snap it back and re-run the whole journey when the
// rider changes his mind mid-slide.
_pillTween = Tween<double>(begin: _pillPos, end: target);
_pillAnim.forward(from: 0);
}
@override
void dispose() {
_pillAnim.dispose();
super.dispose();
}
/// The colour a segment settles on once the pill is over it.
Color _litColourOf(int tripIndex) {
if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText;
return _percentOf(tripIndex) >= 100
? ColorConstants.acceptGreen
: ColorConstants.primary;
}
/// The colour a segment sits at with the pill elsewhere.
///
/// A finished trip stays green whether or not you are looking at it — "Trip 1,
/// 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;
return _percentOf(tripIndex) >= 100
? ColorConstants.acceptGreen
: ColorConstants.secondaryText;
}
int _percentOf(int tripIndex) => widget.trips[tripIndex].completionPercent(
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
);
/// True when this trip has a stop in progress — the rider is on it now.
///
/// Derived rather than stored: a trip is running when any of its stops is, and
/// [stopStateOf] already resolves that from the server status plus the local
/// accept/reject sets.
bool _isRunning(Trip trip) => trip.stops.any(
(s) =>
stopStateOf(
s,
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
) ==
StopState.active,
);
@override
Widget build(BuildContext context) {
final inline = _inlineTabs();
final hidden = _hiddenTabs(inline);
// ── A segmented control on a track ──
//
// Three bordered chips became a tab bar became this: one light track holding
// three segments, with the tab you are looking at marked by a pill that
// slides between them. It is the iOS/Material segmented pattern, and it is
// the right one here because the choice is *exclusive and complete* — a
// rider is always looking at exactly one of his three trips, and a track
// makes that set visible as a set.
return Container(
// 16 — the app's base gutter, and the narrowest inset on this screen.
//
// The cards above sit on 28 and the route below on 32, so the track is
// deliberately the widest element in the column. Two reasons it reads
// right rather than misaligned:
//
// • Every card here carries a drop shadow with an 18–20pt blur, which
// bleeds past the card's box on each side. A card's *visible* edge is
// softly wider than its layout edge, while the track is a flat fill
// with a hard edge and no bleed — so matching the numbers exactly made
// the track look inset further than everything around it. It was
// measured at an identical 28 and still read as narrower.
//
// • A segmented control is a *container* for the three trips, not another
// card in the stack. Running it wider than the content it governs is
// the usual way that reads, and it gives each of the three segments
// more room for its label and status line.
margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h),
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
// LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder
// refuses to answer intrinsic-dimension queries, so nesting it under
// IntrinsicHeight throws rather than laying out.
child: LayoutBuilder(
builder: (context, box) {
// The overflow button is a fixed 48 plus a 6 gap, so the segments
// divide what is left. Without this the pill would sit off its tab
// whenever a hub assigned an unusual day.
final reserved = hidden.isEmpty ? 0.0 : 48.w + 6.w;
final slot = (box.maxWidth - reserved) / inline.length;
return AnimatedBuilder(
animation: _pillAnim,
builder: (context, _) {
final pos = _pillPos;
// The trailing underline travels *with* the pill as one object.
// Left in the segments it would have had to blink out under the
// old tab and in under the new one, which is the behaviour being
// removed. Its colour lerps between the two segments it is
// between, so crossing a finished trip shades red → green → red
// rather than snapping at the midpoint.
final lo = pos.floor().clamp(0, inline.length - 1);
final hi = pos.ceil().clamp(0, inline.length - 1);
final markColour = Color.lerp(
_litColourOf(inline[lo]),
_litColourOf(inline[hi]),
pos - pos.floor(),
)!;
// No fixed height: the tab content is text, and a user with large
// font settings overflowed a hard 54.h by a few pixels.
// IntrinsicHeight sizes the row to its tallest tab instead, so
// every tab matches and nothing can overflow.
return IntrinsicHeight(
child: Stack(
children: [
PositionedDirectional(
start: slot * pos,
top: 0,
bottom: 0,
width: slot,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 2.w),
decoration: BoxDecoration(
// The same light glass red the nav bar uses for its
// selection. A white pill was doing the job with a
// drop shadow — fine on its own, but it made this the
// one selected-state in the app that was not the brand
// 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),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
// Keyed so tests can follow it across the strip.
// Identifying it by colour instead breaks the moment
// it travels over an unassigned trip, where it is
// deliberately grey rather than the brand red.
key: TripTabs.markerKey,
height: 3.h,
margin: EdgeInsets.only(
left: 12.w,
right: 12.w,
bottom: 4.h,
),
decoration: BoxDecoration(
color: markColour,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (
var slotIndex = 0;
slotIndex < inline.length;
slotIndex++
) ...[
Builder(
builder: (context) {
final i = inline[slotIndex];
// How lit this segment is: 1 with the pill on it,
// 0 once the pill is a whole slot away. Cubed, so
// the glow stays tight around the pill — a linear
// ramp leaves every segment it crosses looking
// half-selected, and the control then reads as
// having no clear selection mid-slide.
final d = (pos - slotIndex).abs();
final t = (1.0 - d).clamp(0.0, 1.0);
final lit = t * t * t;
return Expanded(
child: _Tab(
label: Trip.tabLabel(i),
percent: i < widget.trips.length
? _percentOf(i)
: 0,
assigned: i < widget.trips.length,
selected: i == widget.selectedIndex,
active:
i < widget.trips.length &&
_isRunning(widget.trips[i]),
lit: lit,
restColour: _restColourOf(i),
litColour: _litColourOf(i),
onTap: () => widget.onSelect(i),
),
);
},
),
if (hidden.isNotEmpty &&
slotIndex == inline.length - 1)
SizedBox(width: 6.w),
],
if (hidden.isNotEmpty)
_OverflowMenu(
trips: widget.trips,
hidden: hidden,
onSelect: widget.onSelect,
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
),
],
),
],
),
);
},
);
},
),
);
}
}
class TripTabsHeader extends SliverPersistentHeaderDelegate {
final List<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
final Set<String> acceptedIds;
final Set<String> rejectedIds;
/// Reserved height. Pass [extentFor] unless you have measured otherwise.
final double extent;
const TripTabsHeader({
required this.trips,
required this.selectedIndex,
required this.onSelect,
required this.extent,
this.acceptedIds = const {},
this.rejectedIds = const {},
});
/// Height to reserve for the bar at this context's text scale.
///
/// Sums what a tab is actually made of — outer margin, border, padding, one
/// line of label, the gap, the progress underline — then adds a deliberate
/// safety margin. Over-reserving is the safe direction; see the class note.
static double extentFor(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final labelLine = scaler.scale(12.5.sp) * 1.4;
final statusLine = scaler.scale(10.5.sp) * 1.4;
return 6
.h // Container margin, 2 top + 4 bottom
+
6
.w // track padding, 3 top + 3 bottom
+
5
.h // lead-in above the label
+
labelLine +
1
.h // gap between label and status
+
statusLine +
4
.h // gap above the running underline
+
3
.h // the underline
+
4
.h // tail below it
+
6; // safety, so a rounding error adds air rather than a clip
}
@override
double get minExtent => extent;
@override
double get maxExtent => extent;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return _PinnedSurface(
// True exactly when stops have scrolled underneath the bar. That is the
// moment it stops being part of the page and starts floating over it, so
// it is the moment it needs an edge — without one the tabs and the top of
// a stop card blur into each other.
floating: overlapsContent || shrinkOffset > 0,
child: TripTabs(
trips: trips,
selectedIndex: selectedIndex,
onSelect: onSelect,
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
),
);
}
@override
bool shouldRebuild(TripTabsHeader old) =>
old.extent != extent ||
old.selectedIndex != selectedIndex ||
old.trips != trips ||
old.acceptedIds != acceptedIds ||
old.rejectedIds != rejectedIds;
}
/// Opaque backing for the pinned bar, with an edge that appears only once
/// content is passing underneath.
class _PinnedSurface extends StatelessWidget {
final bool floating;
final Widget child;
const _PinnedSurface({required this.floating, required this.child});
@override
Widget build(BuildContext context) {
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,
border: Border(
bottom: BorderSide(
color: floating ? ColorConstants.borderStrong : Colors.transparent,
width: 1,
),
),
),
// Centred rather than stretched: the reserved extent over-estimates by
// design, so the spare pixels become air above and below instead of
// stretching the tabs into a shape they were not designed for.
child: Center(child: child),
);
}
}
class _Tab extends StatelessWidget {
final String label;
final int percent;
/// False when the hub has not assigned this trip yet.
final bool assigned;
/// The tab the rider is looking at. Used for semantics only — everything
/// painted follows [lit] instead, so the label tracks the pill rather than
/// flipping the instant it is tapped.
final bool selected;
/// The trip actually under way.
final bool active;
/// How close the travelling pill is, 0 → 1.
final double lit;
/// Colour with the pill elsewhere, and colour with the pill on this tab.
final Color restColour;
final Color litColour;
final VoidCallback onTap;
const _Tab({
required this.label,
required this.percent,
required this.selected,
required this.lit,
required this.restColour,
required this.litColour,
required this.onTap,
this.assigned = true,
this.active = false,
});
@override
Widget build(BuildContext context) {
final complete = assigned && percent >= 100;
// ── One thing is marked, and it is the tab you are on ──
//
// Colour follows the pill. It briefly followed a separate "this trip is
// running" signal, and the result on a real screen was that Trip 1 sat
// permanently red and underlined while the rider stood on Trip 2 — two tabs
// claiming to be the important one, and the tap he had just made appearing
// to do nothing. A navigation control has to answer "where am I" before it
// answers anything else.
//
// Running is not lost, it is demoted: the tab still says "Active" in its
// status line, in whatever colour that tab's state calls for. That is the
// right weight for it — useful to know, not something to fight the selection
// for the eye.
final Color accent = Color.lerp(restColour, litColour, lit)!;
final String status = !assigned
? 'Not set'
: complete
? 'Done'
: active
? 'Active'
: percent > 0
? '$percent% done'
: '$percent%';
return Semantics(
button: true,
selected: selected,
label: assigned
? '$label, $status${active ? ', in progress' : ''}'
: '$label, not assigned yet',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 5.h),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
// Progress as words, not a bar. The underline is reserved for "this
// is the one you are on", and one mark cannot mean two things — but
// "how far through is each trip" is why tabs beat a dropdown here,
// so the figure has to survive somewhere legible.
Text(
status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 4.h),
// Where the underline used to be drawn. It now travels with the
// pill, but the space it occupied is still reserved here so every
// segment keeps the height `TripTabsHeader.extentFor` reserves for
// it and nothing shifts.
SizedBox(height: 3.h),
SizedBox(height: 4.h),
],
),
),
);
}
}
/// "More" button holding trips 4+ when a hub assigns an unusual day.
class _OverflowMenu extends StatelessWidget {
final List<Trip> trips;
final List<int> hidden;
final ValueChanged<int> onSelect;
final Set<String> acceptedIds;
final Set<String> rejectedIds;
const _OverflowMenu({
required this.trips,
required this.hidden,
required this.onSelect,
required this.acceptedIds,
required this.rejectedIds,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: '${hidden.length} more trips',
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: PopupMenuButton<int>(
onSelected: onSelect,
tooltip: 'More trips',
position: PopupMenuPosition.under,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
itemBuilder: (_) => [
for (final i in hidden)
PopupMenuItem<int>(
value: i,
child: Row(
children: [
Text(
Trip.tabLabel(i),
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 8.w),
Text(
i < trips.length ? trips[i].slotLabel : 'Not assigned',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
child: Container(
width: 48.w,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.expand_more_rounded,
size: 22.sp,
color: ColorConstants.slateText,
),
Text(
'+${hidden.length}',
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
);
}
}