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,752 @@
import 'dart:math' as math;
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A TRIP — one slot's worth of work, as a single unit.
///
/// The hub manager does not assign bookings one at a time. He takes every
/// customer booking that falls inside a slot (say 10:00–13:00), orders them
/// into a route, and hands the whole thing to one miler. So the rider's
/// decision is never "do I want this one parcel?" — it is "do I take this
/// trip?". Home used to show loose bookings, which framed the job as a
/// marketplace it isn't, and hid the two facts that actually matter: how long
/// the whole run takes, and where it starts and ends.
///
/// A trip is therefore always shaped:
///
/// HUB → Stop 1 → Stop 2 → … → Stop N → HUB
///
/// with the same hub at both ends. Every duration and distance below includes
/// the return leg, because the rider is not finished until he is back.
///
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
/// and safe to call from `build`.
/// ─────────────────────────────────────────────────────────────────────────
class Trip {
/// Stable identity for this trip (backend slot/trip id, or a derived key).
final String id;
/// The customer-facing slot window this trip serves. Null when the backend
/// sends no usable times — the trip is then simply "today's route".
final DateTime? slotStart;
final DateTime? slotEnd;
/// Stops in the admin's fixed order. Never re-sorted by the app.
final List<Map<String, dynamic>> stops;
/// Straight-line round-trip length in metres, hub → stops → hub.
final double routeMeters;
/// Sum of the per-stop service estimates (time off the bike).
final Duration serviceDuration;
/// Time on the bike, at [RouteMetricsHelper.kDefaultSpeedMps].
final Duration travelDuration;
final int totalParcels;
final int deliverParcels;
final int collectParcels;
final double totalWeightKg;
final double cashToCollect;
const Trip({
required this.id,
required this.slotStart,
required this.slotEnd,
required this.stops,
required this.routeMeters,
required this.serviceDuration,
required this.travelDuration,
required this.totalParcels,
required this.deliverParcels,
required this.collectParcels,
required this.totalWeightKg,
required this.cashToCollect,
});
int get stopCount => stops.length;
/// The number the rider is actually planning around: ride time plus every
/// minute spent standing at a door.
Duration get totalDuration => travelDuration + serviceDuration;
/// Average time per stop across the whole trip, service only.
Duration get averageStopDuration => stopCount == 0
? Duration.zero
: Duration(seconds: serviceDuration.inSeconds ~/ stopCount);
/// `10:00 AM – 1:00 PM`, or a plain label when the slot is unknown.
String get slotLabel {
final s = slotStart;
final e = slotEnd;
if (s == null || e == null) return "Today's route";
return '${RouteMetricsHelper.formatClock(s)} – '
'${RouteMetricsHelper.formatClock(e)}';
}
/// True when the slot window has already closed.
bool isExpired([DateTime? now]) {
final e = slotEnd;
if (e == null) return false;
return (now ?? DateTime.now()).isAfter(e);
}
/// Order ids in this trip.
List<String> get orderIds => [
for (final s in stops)
if ((s['orderid'] ?? '').toString().isNotEmpty) (s['orderid']).toString(),
];
// ── Per-stop state & completion ────────────────────────────────────────
/// State of every stop, in route order.
List<StopState> states({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds),
];
/// Ids an "accept whole trip" action should operate on — only the stops
/// still awaiting a decision. Re-accepting one already accepted would be a
/// wasted call, and re-accepting a rejected one would undo the rider.
List<String> pendingOrderIds({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
if (stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds) ==
StopState.pending)
(s['orderid'] ?? '').toString(),
].where((id) => id.isNotEmpty).toList();
/// Fraction of the trip that is finished, 0.0 → 1.0.
///
/// Counts **resolved** stops — done or rejected — not just completed ones.
/// A rejected stop is off the rider's plate; leaving it out of the numerator
/// would strand the trip below 100% with nothing he could do about it.
double completion({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) {
if (stops.isEmpty) return 0;
final resolved = states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).where((s) => s.isResolved).length;
return resolved / stops.length;
}
/// Completion as a whole percent, for display.
int completionPercent({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => (completion(acceptedIds: acceptedIds, rejectedIds: rejectedIds) * 100)
.round();
/// True once every stop has been resolved — the rider can head back.
bool isFinished({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) =>
stops.isNotEmpty &&
states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).every((s) => s.isResolved);
/// True while any stop still needs an accept/reject decision.
bool hasPendingStops({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).any((s) => s.needsDecision);
/// Short tab label — `Trip 1`.
static String tabLabel(int index) => 'Trip ${index + 1}';
/// Address components every stop on this trip shares — city, state,
/// pincode and so on. Cached per build by the caller.
List<String> get sharedAddressTail => commonAddressTail([
for (final s in stops) (s['pickupaddress'] ?? '').toString(),
]);
/// The distinguishing part of a stop's address, with the shared tail removed.
String shortAddress(Map<String, dynamic> stop, List<String> tail) =>
stripAddressTail((stop['pickupaddress'] ?? '').toString(), tail);
// ── Per-stop estimates ─────────────────────────────────────────────────
/// Estimated time OFF the bike at [index].
///
/// Service time is not a constant. Handing a parcel over and reading back an
/// OTP is quick; weighing and photographing a collection is slower; doing
/// both at one door is slower still; and counting cash adds real minutes.
/// These are the numbers the pace warning on Home is measured against, so
/// they are deliberately not optimistic.
static Duration serviceTimeFor(Map<String, dynamic> stop) {
final kind = stopKindOf(stop);
var seconds = switch (kind) {
StopKind.delivery => 150, // 2m30 — hand over, OTP, photo
StopKind.pickup => 240, // 4m00 — collect, weigh, photo
StopKind.combined => 330, // 5m30 — both, but one walk-up
};
// Cash handling is the single biggest variable at a door.
if (stopCollectionAmount(stop) > 0) seconds += 90;
// Multi-parcel stops take longer to count and load. First parcel is in
// the base estimate; each extra adds handling time.
final parcels = deliveryParcelCount(stop) + pickupParcelCount(stop);
if (parcels > 1) seconds += (parcels - 1) * 25;
return Duration(seconds: seconds);
}
/// Estimated ride time from the previous point to stop [index]. For index 0
/// that is the hub → stop 1 leg.
Duration travelTimeToStop(int index, {double? hubLat, double? hubLng}) {
if (index < 0 || index >= stops.length) return Duration.zero;
final ({double lat, double lng})? from = index == 0
? _origin(hubLat, hubLng)
: _coordsOf(stops[index - 1]);
final to = _coordsOf(stops[index]);
if (from == null || to == null) return Duration.zero;
final meters = RouteMetricsHelper.distanceMeters(
from.lat,
from.lng,
to.lat,
to.lng,
);
return RouteMetricsHelper.travelTime(meters);
}
/// Running clock estimate of when the rider reaches stop [index], assuming
/// he leaves the hub at [departure].
DateTime etaForStop(
int index, {
required DateTime departure,
double? hubLat,
double? hubLng,
}) {
var clock = departure;
for (var i = 0; i <= index && i < stops.length; i++) {
clock = clock.add(travelTimeToStop(i, hubLat: hubLat, hubLng: hubLng));
if (i < index) clock = clock.add(serviceTimeFor(stops[i]));
}
return clock;
}
({double lat, double lng})? _origin(double? lat, double? lng) =>
(lat != null && lng != null && lat != 0 && lng != 0)
? (lat: lat, lng: lng)
: null;
// ── Construction ───────────────────────────────────────────────────────
/// Builds one trip from an ordered list of stops.
///
/// [hubLat]/[hubLng] anchor both ends of the round trip. The backend does
/// not yet send hub coordinates, so callers pass the rider's current
/// position — he is at (or near) the hub when he picks up a trip, which
/// makes it a workable stand-in. When it is missing, the route length is the
/// stop-to-stop chain only and the return leg is simply absent rather than
/// guessed.
factory Trip.fromStops({
required String id,
required List<Map<String, dynamic>> stops,
DateTime? slotStart,
DateTime? slotEnd,
double? hubLat,
double? hubLng,
}) {
final ordered = sortStops(stops);
int parcels = 0;
int deliver = 0;
int collect = 0;
double weight = 0;
double cash = 0;
var service = Duration.zero;
for (final s in ordered) {
final d = deliveryParcelCount(s);
final c = pickupParcelCount(s);
deliver += d;
collect += c;
parcels += (d + c) > 0 ? (d + c) : 1;
weight += _toDouble(s['weight'] ?? s['Weight']);
cash += stopCollectionAmount(s);
service += serviceTimeFor(s);
}
// Round trip: hub → every stop in order → back to the hub.
final chain = <({double lat, double lng})>[];
final origin =
(hubLat != null && hubLng != null && hubLat != 0 && hubLng != 0)
? (lat: hubLat, lng: hubLng)
: null;
if (origin != null) chain.add(origin);
for (final s in ordered) {
final c = _coordsOf(s);
if (c != null) chain.add(c);
}
if (origin != null && chain.length > 1) chain.add(origin);
double meters = 0;
for (var i = 0; i < chain.length - 1; i++) {
meters += RouteMetricsHelper.distanceMeters(
chain[i].lat,
chain[i].lng,
chain[i + 1].lat,
chain[i + 1].lng,
);
}
if (meters == 0) {
// Fall back to whatever per-stop kilometres the backend sent.
for (final s in ordered) {
meters += _toDouble(s['kms'] ?? s['km']) * 1000;
}
}
return Trip(
id: id,
slotStart: slotStart,
slotEnd: slotEnd,
stops: ordered,
routeMeters: meters,
serviceDuration: service,
travelDuration: RouteMetricsHelper.travelTime(meters),
totalParcels: parcels,
deliverParcels: deliver,
collectParcels: collect,
totalWeightKg: weight,
cashToCollect: cash,
);
}
/// Groups loose stops into trips.
///
/// Grouping key, in order of trust:
/// 1. An explicit backend `tripid` / `slotid` / `routeid`.
/// 2. An explicit slot window (`slotstarttime` + `slotendtime`).
/// 3. The `expected_pickup_time` bucketed into a [windowHours] block
/// aligned to the clock — 10:00–13:00, 13:00–16:00, and so on. This is
/// how the hub builds slots, so bucketing reproduces them closely enough
/// to be useful while the backend has no trip field.
/// 4. Everything else lands in one "today's route" trip rather than being
/// dropped.
///
/// Returned trips are ordered by slot start, earliest first.
/// The most trips a rider's day can contain.
///
/// Three is the operational fact, not a display limit: the hub assigns morning,
/// afternoon and evening slots. [TripTabs] renders three slots for the same
/// reason, and [_capToMaxTrips] folds any extra buckets into the last so the
/// two never disagree.
static const int maxTripsPerDay = 3;
static List<Trip> groupIntoTrips(
List<Map<String, dynamic>> stops, {
int windowHours = 3,
double? hubLat,
double? hubLng,
DateTime? now,
}) {
if (stops.isEmpty) return const [];
final clock = now ?? DateTime.now();
final buckets = <String, List<Map<String, dynamic>>>{};
final windows = <String, (DateTime?, DateTime?)>{};
for (final stop in stops) {
final explicitId = _firstNonEmpty(stop, [
'tripid',
'tripId',
'slotid',
'slotId',
'routeid',
'routeId',
]);
final slotFrom = _parseTimestamp(
stop['slotstarttime'] ?? stop['slotStartTime'] ?? stop['slotfrom'],
);
final slotTo = _parseTimestamp(
stop['slotendtime'] ?? stop['slotEndTime'] ?? stop['slotto'],
);
String key;
DateTime? from;
DateTime? to;
if (explicitId != null) {
key = 'id:$explicitId';
from = slotFrom;
to = slotTo;
} else if (slotFrom != null && slotTo != null) {
key = 'slot:${slotFrom.toIso8601String()}';
from = slotFrom;
to = slotTo;
} else {
final due = _parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
stop['eta'],
);
if (due != null) {
final blockHour = (due.hour ~/ windowHours) * windowHours;
from = DateTime(due.year, due.month, due.day, blockHour);
to = from.add(Duration(hours: windowHours));
key = 'win:${from.toIso8601String()}';
} else {
from = null;
to = null;
key = 'day:${clock.year}-${clock.month}-${clock.day}';
}
}
buckets.putIfAbsent(key, () => []).add(stop);
windows.putIfAbsent(key, () => (from, to));
}
final trips = <Trip>[
for (final entry in buckets.entries)
Trip.fromStops(
id: entry.key,
stops: entry.value,
slotStart: windows[entry.key]?.$1,
slotEnd: windows[entry.key]?.$2,
hubLat: hubLat,
hubLng: hubLng,
),
];
trips.sort((a, b) {
final as = a.slotStart;
final bs = b.slotStart;
if (as == null && bs == null) return 0;
if (as == null) return 1; // undated trips last
if (bs == null) return -1;
return as.compareTo(bs);
});
return _capToMaxTrips(trips, hubLat: hubLat, hubLng: hubLng);
}
/// A rider's day is three trips. Anything past that is folded into the last.
///
/// The bucketing above is time-based — a stop with no trip id lands in a
/// three-hour window — so a long day, or one stop booked well outside the
/// others, can produce a fourth or fifth bucket. That surfaced as a "Trip 4"
/// tab, which is not a thing the hub assigns.
///
/// **Merged, never dropped.** Truncating the list would be the one-line fix
/// and it would silently hide real work: those stops are accepted bookings the
/// rider is expected to complete, and a stop he cannot see is a stop he cannot
/// deliver. The tail is therefore folded into the third trip, whose slot then
/// spans from its own start to the last stop's end, so the label stays honest
/// about what it now contains.
static List<Trip> _capToMaxTrips(
List<Trip> trips, {
double? hubLat,
double? hubLng,
}) {
if (trips.length <= maxTripsPerDay) return trips;
final kept = trips.take(maxTripsPerDay - 1).toList();
final tail = trips.skip(maxTripsPerDay - 1).toList();
final mergedStops = [for (final t in tail) ...t.stops];
final starts = tail.map((t) => t.slotStart).whereType<DateTime>();
final ends = tail.map((t) => t.slotEnd).whereType<DateTime>();
kept.add(
Trip.fromStops(
id: tail.first.id,
stops: mergedStops,
slotStart: starts.isEmpty
? null
: starts.reduce((a, b) => a.isBefore(b) ? a : b),
slotEnd: ends.isEmpty
? null
: ends.reduce((a, b) => a.isAfter(b) ? a : b),
hubLat: hubLat,
hubLng: hubLng,
),
);
return kept;
}
/// Puts stops in the admin's intended order.
///
/// `step` is authoritative — it IS the admin's solved sequence, and the app
/// must never second-guess it. Only when `step` is absent do we fall back to
/// the booked time, then to the order the backend sent. Sorting by distance
/// would be re-optimising the route, which the rider is not allowed to do.
static List<Map<String, dynamic>> sortStops(
List<Map<String, dynamic>> stops,
) {
final indexed = <(int, Map<String, dynamic>)>[
for (var i = 0; i < stops.length; i++) (i, stops[i]),
];
indexed.sort((a, b) {
final stepA = _toInt(a.$2['step'] ?? a.$2['Step']);
final stepB = _toInt(b.$2['step'] ?? b.$2['Step']);
if (stepA > 0 && stepB > 0 && stepA != stepB) return stepA - stepB;
if (stepA > 0 && stepB <= 0) return -1;
if (stepB > 0 && stepA <= 0) return 1;
final dueA = _parseTimestamp(a.$2['expected_pickup_time']);
final dueB = _parseTimestamp(b.$2['expected_pickup_time']);
if (dueA != null && dueB != null && dueA != dueB) {
return dueA.compareTo(dueB);
}
return a.$1 - b.$1; // stable: keep backend order
});
return [for (final e in indexed) e.$2];
}
// ── helpers ────────────────────────────────────────────────────────────
static ({double lat, double lng})? _coordsOf(Map<String, dynamic> stop) {
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (lat == 0 || lng == 0) return null;
return (lat: lat, lng: lng);
}
static String? _firstNonEmpty(Map<String, dynamic> m, List<String> keys) {
for (final k in keys) {
final v = m[k];
final s = v?.toString().trim() ?? '';
if (s.isNotEmpty && s != '0' && s != 'null') return s;
}
return null;
}
static DateTime? _parseTimestamp(dynamic v) {
if (v == null) return null;
return DateTime.tryParse(v.toString().trim());
}
static int _toInt(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toInt();
return int.tryParse(v.toString().trim()) ?? 0;
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
}
/// ─────────────────────────────────────────────────────────────────────────
/// ADDRESS COMPRESSION
///
/// A raw stop address is written for a postal system, not a rider:
///
/// "5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041"
///
/// On a six-stop trip, "Coimbatore, Tamil Nadu, 641041" is repeated six times
/// and distinguishes nothing — every stop is in the same city, or the hub
/// would not have put them on one route. It is pure noise, and it is the noise
/// that pushes the useful part ("5/2, North Avenue") onto a second line and
/// then off the end of an ellipsis.
///
/// So: find the trailing components EVERY stop on the trip shares, and drop
/// them. What survives is exactly the part that tells one stop from another.
/// The full address is never lost — it goes to the detail sheet and to the
/// navigation hand-off untouched.
///
/// This is data-driven rather than a hardcoded list of cities and states: it
/// works for any city, any country, and degrades to a no-op on a one-stop trip
/// (where nothing is shared, so nothing is dropped).
/// ─────────────────────────────────────────────────────────────────────────
/// Splits an address into trimmed, non-empty components.
List<String> addressParts(String address) =>
address.split(',').map((p) => p.trim()).where((p) => p.isNotEmpty).toList();
/// The trailing components shared by every address in [addresses].
///
/// Returns an empty list when fewer than two addresses are given, or when the
/// tails differ — there is then nothing redundant to remove.
List<String> commonAddressTail(List<String> addresses) {
final parts = [
for (final a in addresses)
if (addressParts(a).isNotEmpty) addressParts(a),
];
if (parts.length < 2) return const [];
final shortest = parts.map((p) => p.length).reduce((a, b) => a < b ? a : b);
final tail = <String>[];
for (var back = 1; back < shortest; back++) {
final candidate = parts.first[parts.first.length - back].toLowerCase();
final shared = parts.every(
(p) => p[p.length - back].toLowerCase() == candidate,
);
if (!shared) break;
tail.insert(0, parts.first[parts.first.length - back]);
}
return tail;
}
/// [address] with [tail] removed. Always leaves at least one component, so a
/// stop can never render a blank address.
String stripAddressTail(String address, List<String> tail) {
if (tail.isEmpty) return address.trim();
final parts = addressParts(address);
var end = parts.length;
for (var i = tail.length - 1; i >= 0; i--) {
if (end <= 1) break;
if (parts[end - 1].toLowerCase() != tail[i].toLowerCase()) break;
end--;
}
return parts.sublist(0, end).join(', ');
}
/// Where a single stop sits in the accept → work → done lifecycle.
///
/// This is what lets one trip card serve both Home and Bookings. A trip does
/// not leave Home the moment it is accepted — the rider still wants to watch
/// it fill up — so every stop carries its own state and the card renders the
/// right control for it: Accept/Reject while pending, a progress tick once
/// it's moving, nothing at all once it's done.
enum StopState {
/// Awaiting the rider's decision. Shows Accept + Reject.
pending,
/// Accepted, not yet started. Lives on the Bookings tab.
accepted,
/// The rider is physically on this stop right now.
active,
/// Picked up / delivered.
done,
/// Skipped, awaiting a return visit. Not a failure.
skipped,
/// The rider declined this stop. It stays visible, struck through, so he
/// can see he handled it rather than wondering where it went.
rejected,
}
extension StopStateX on StopState {
/// Counts toward "trip complete". A rejected stop is resolved — the rider
/// has nothing left to do with it — so it must not hold the trip at 90%
/// forever.
bool get isResolved => this == StopState.done || this == StopState.rejected;
/// Still needs a decision from the rider.
bool get needsDecision => this == StopState.pending;
/// Committed to: he has taken it and owes the customer a visit.
bool get isCommitted =>
this == StopState.accepted ||
this == StopState.active ||
this == StopState.skipped;
String get label => switch (this) {
StopState.pending => 'Awaiting your decision',
StopState.accepted => 'Accepted',
StopState.active => 'In progress',
StopState.done => 'Completed',
StopState.skipped => 'Skipped',
StopState.rejected => 'Rejected',
};
}
/// Derives a stop's state from its backend status plus the local
/// accepted/rejected stores.
///
/// Local stores win for accept/reject because those writes are optimistic —
/// the rider's tap is recorded instantly and the network catches up. If the
/// server view won, a slow response would bounce a stop back to "pending"
/// under his thumb.
StopState stopStateOf(
Map<String, dynamic> stop, {
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) {
final id = (stop['orderid'] ?? '').toString();
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
// 1. Irreversible server states win over everything. A parcel that has been
// collected or handed over cannot be un-decided by a local tap.
if (raw == 'picked' ||
raw == 'picked up' ||
raw == 'pickedup' ||
raw == 'pickuped' ||
raw == 'delivered' ||
raw == 'cancelled' ||
raw == 'canceled') {
return StopState.done;
}
// 2. The rider is physically on the stop — also not a local decision.
if (raw == 'active' || raw == 'arrived') return StopState.active;
if (raw == 'skipped') return StopState.skipped;
// 3. Local decisions next, and they beat the server's accept/reject.
// They are strictly newer: the rider just tapped, and the server view is
// at best one poll behind. Without this, un-rejecting a stop would be
// undone by the next refetch still reporting `rejected`.
if (rejectedIds.contains(id)) return StopState.rejected;
if (acceptedIds.contains(id)) return StopState.accepted;
// 4. Finally the server's own view.
if (raw == 'rejected') return StopState.rejected;
if (raw == 'accepted') return StopState.accepted;
return StopState.pending;
}
/// How a stop is progressing through the trip, for the progress rail.
enum StopProgress {
/// Finished — picked up / delivered. Rail turns green here.
done,
/// The stop the rider is on right now.
current,
/// Not started.
pending,
/// Skipped and awaiting a return visit.
skipped,
}
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
String formatTripDuration(Duration d) {
if (d.inMinutes < 1) return '—';
if (d.inMinutes < 60) return '${d.inMinutes} min';
final minutes = d.inMinutes.remainder(60);
return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m';
}
/// Rounds a duration up to the nearest 5 minutes.
///
/// An estimate printed as "37 min" claims a precision the arithmetic does not
/// have and invites the rider to treat it as a promise. "40 min" reads as
/// what it is.
Duration roundTripDuration(Duration d) {
if (d.inSeconds <= 0) return Duration.zero;
final minutes = (d.inMinutes / 5).ceil() * 5;
return Duration(minutes: math.max(5, minutes));
}