first commit

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

View File

@@ -0,0 +1,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,
),
),
],
),
);
}
}