Files
doormile_milderapp/lib/views/Dashboard/home/route_brief.dart
2026-08-11 13:16:33 +05:30

393 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
],
),
);
}
}