/// ───────────────────────────────────────────────────────────────────────── /// 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 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 stop) { final raw = stop['compliance']; final Map m = raw is Map ? Map.from(raw) : const {}; 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> 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, ); } }