first commit
This commit is contained in:
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user