import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.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 orderIds, required Map> ordersMap, required String shiftStart, required String shiftEnd, List> 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 = >[ 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(LucideIcons.clock, color: ColorConstants.primary, size: 15.sp), SizedBox(width: 7.w), Text( brief.shiftWindowLabel, style: TextStyle( color: ColorConstants.slateText, fontSize: 12.5.sp, fontWeight: FontWeight.w600, 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.w600, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } }