Files
doormile_milderapp/lib/data/stop_compliance.dart
2026-08-11 13:16:33 +05:30

211 lines
7.5 KiB
Dart

/// ─────────────────────────────────────────────────────────────────────────
/// STOP COMPLIANCE — did the rider make the ETA, and did he take the route?
///
/// Both facts were already being computed and then thrown away. The bonus-point
/// rule in `PickupsController.updatePickedupStatus` is literally "was this
/// closed before `eta_endtime`", and the distance travelled to the stop is
/// measured to bill rider charges. Neither ever reached the rider: he was paid
/// (or not) for punctuality he could not see, and "did I go the way the hub
/// routed me" had no answer anywhere in the app.
///
/// This is that pair, stamped onto the stop when it is completed and read back
/// on the Activity tab and in Rewards.
///
/// ── Why it is stamped and not recomputed ──
///
/// The inputs are gone by the time anyone looks: the ETA deadline lives in a
/// SharedPreferences key that the next stop overwrites, and the distance is
/// measured from the previous stop's location, which has also moved on. The
/// only moment both are true is the moment of completion, so that is where the
/// record is written.
///
/// ── Unknown is a real answer ──
///
/// A hub that assigns no ETA, a stop whose GPS never resolved: those are not
/// failures by the rider and are never shown as one. Every field is nullable
/// and the UI says "not measured" rather than inventing a verdict — a
/// compliance score that quietly counts unknowns as misses is a score that
/// punishes riders for the back office's gaps.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// How far past the planned distance still counts as "took the route".
///
/// A route figure is a straight-line-ish estimate from the hub's planner, and a
/// rider obeys one-ways, diversions and closed roads it knows nothing about. A
/// quarter over plus half a kilometre of slack is the difference between "took
/// a detour" and "went somewhere else" — tight enough to catch a genuine
/// wander, loose enough that ordinary traffic never trips it.
const double kRouteTolerance = 1.25;
const double kRouteGraceKm = 0.5;
class StopCompliance {
/// Closed before the ETA deadline. Null when no ETA was ever set.
final bool? onTime;
/// How late, when [onTime] is false.
final Duration? lateBy;
/// Distance actually ridden to this stop, and what the hub planned.
final double? actualKm;
final double? plannedKm;
const StopCompliance({
this.onTime,
this.lateBy,
this.actualKm,
this.plannedKm,
});
static const StopCompliance unknown = StopCompliance();
/// Took the assigned route, within [kRouteTolerance]. Null when either
/// distance is missing — a planned figure of zero is missing, not perfect.
bool? get onRoute {
final actual = actualKm;
final planned = plannedKm;
if (actual == null || planned == null || planned <= 0 || actual <= 0) {
return null;
}
return actual <= (planned * kRouteTolerance) + kRouteGraceKm;
}
/// Kilometres beyond what the route allowed for. Null unless [onRoute] is
/// false — there is no "negative detour" worth reporting.
double? get extraKm {
if (onRoute != false) return null;
return (actualKm! - plannedKm!).clamp(0, double.infinity).toDouble();
}
/// True only when both halves are known and both are met. This is the one
/// that earns the reward.
bool get isClean => onTime == true && onRoute == true;
/// Nothing was measurable for this stop.
bool get isUnknown => onTime == null && onRoute == null;
Map<String, dynamic> toJson() => {
if (onTime != null) 'ontime': onTime,
if (lateBy != null) 'latebyseconds': lateBy!.inSeconds,
if (actualKm != null) 'actualkm': actualKm,
if (plannedKm != null) 'plannedkm': plannedKm,
};
/// Reads the compliance of a finished stop.
///
/// Prefers the stamp written at completion; falls back to whatever the
/// backend's own row carries, so a stop completed before this existed — or on
/// another device — still says what it can.
static StopCompliance of(Map<String, dynamic> stop) {
final raw = stop['compliance'];
final Map<String, dynamic> m = raw is Map
? Map<String, dynamic>.from(raw)
: const <String, dynamic>{};
final bool? onTime = _bool(m['ontime']);
final int? lateSeconds = _int(m['latebyseconds']);
return StopCompliance(
onTime: onTime,
lateBy: lateSeconds == null ? null : Duration(seconds: lateSeconds),
// `riderkms` is the backend's own name for the distance ridden, so a row
// that came straight from the API reads without a stamp.
actualKm: _double(m['actualkm']) ?? _double(stop['riderkms']),
plannedKm: _double(m['plannedkm']) ?? _double(stop['kms']),
);
}
static bool? _bool(dynamic v) {
if (v is bool) return v;
if (v is num) return v != 0;
final s = v?.toString().toLowerCase().trim();
if (s == 'true' || s == '1') return true;
if (s == 'false' || s == '0') return false;
return null;
}
static int? _int(dynamic v) {
if (v is num) return v.toInt();
return int.tryParse(v?.toString().trim() ?? '');
}
static double? _double(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
final parsed = double.tryParse(
v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''),
);
if (parsed == null || parsed == 0) return null;
return parsed;
}
}
/// The day's compliance, for the Rewards page.
class ComplianceSummary {
/// Stops that could be judged at all — the denominator.
final int measured;
final int onTime;
final int onRoute;
/// Stops where both held. This is what the reward is paid on.
final int clean;
/// Finished stops with nothing measurable, kept separate so they are never
/// silently counted as misses.
final int unmeasured;
const ComplianceSummary({
required this.measured,
required this.onTime,
required this.onRoute,
required this.clean,
required this.unmeasured,
});
static const ComplianceSummary empty = ComplianceSummary(
measured: 0,
onTime: 0,
onRoute: 0,
clean: 0,
unmeasured: 0,
);
int get onTimePercent =>
measured == 0 ? 0 : ((onTime / measured) * 100).round();
int get onRoutePercent =>
measured == 0 ? 0 : ((onRoute / measured) * 100).round();
int get cleanPercent =>
measured == 0 ? 0 : ((clean / measured) * 100).round();
/// Points earned: one per stop that made both its ETA and its route.
///
/// Deliberately a count and not a percentage — a rider who did four clean
/// stops out of four has not earned the same as one who did twelve, and a
/// percentage would say he had.
int get points => clean;
static ComplianceSummary from(Iterable<Map<String, dynamic>> stops) {
int measured = 0, onTime = 0, onRoute = 0, clean = 0, unmeasured = 0;
for (final stop in stops) {
final c = StopCompliance.of(stop);
if (c.isUnknown) {
unmeasured++;
continue;
}
measured++;
if (c.onTime == true) onTime++;
if (c.onRoute == true) onRoute++;
if (c.isClean) clean++;
}
return ComplianceSummary(
measured: measured,
onTime: onTime,
onRoute: onRoute,
clean: clean,
unmeasured: unmeasured,
);
}
}