393 lines
15 KiB
Dart
393 lines
15 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|