first commit
This commit is contained in:
139
lib/views/Dashboard/home/accepted_pill.dart
Normal file
139
lib/views/Dashboard/home/accepted_pill.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
481
lib/views/Dashboard/home/duty_sheet.dart
Normal file
481
lib/views/Dashboard/home/duty_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
4090
lib/views/Dashboard/home/homepage.dart
Normal file
4090
lib/views/Dashboard/home/homepage.dart
Normal file
File diff suppressed because it is too large
Load Diff
328
lib/views/Dashboard/home/homepage_banner.dart
Normal file
328
lib/views/Dashboard/home/homepage_banner.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
392
lib/views/Dashboard/home/route_brief.dart
Normal file
392
lib/views/Dashboard/home/route_brief.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
202
lib/views/Dashboard/home/selection_bar.dart
Normal file
202
lib/views/Dashboard/home/selection_bar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
596
lib/views/Dashboard/home/stop_detail_sheet.dart
Normal file
596
lib/views/Dashboard/home/stop_detail_sheet.dart
Normal 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 (_) {}
|
||||
}
|
||||
}
|
||||
752
lib/views/Dashboard/home/trip.dart
Normal file
752
lib/views/Dashboard/home/trip.dart
Normal 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));
|
||||
}
|
||||
590
lib/views/Dashboard/home/trip_brief_strip.dart
Normal file
590
lib/views/Dashboard/home/trip_brief_strip.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1547
lib/views/Dashboard/home/trip_card.dart
Normal file
1547
lib/views/Dashboard/home/trip_card.dart
Normal file
File diff suppressed because it is too large
Load Diff
264
lib/views/Dashboard/home/trip_empty_state.dart
Normal file
264
lib/views/Dashboard/home/trip_empty_state.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
491
lib/views/Dashboard/home/trip_progress_rail.dart
Normal file
491
lib/views/Dashboard/home/trip_progress_rail.dart
Normal 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;
|
||||
707
lib/views/Dashboard/home/trip_tabs.dart
Normal file
707
lib/views/Dashboard/home/trip_tabs.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user