Files
doormile_milderapp/lib/controllers/riderkm.dart
2026-08-28 11:13:15 +05:30

160 lines
6.7 KiB
Dart

import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE WEEK'S KILOMETRES
///
/// ── Why the chart was empty ──
///
/// This read `data.breakdown` off `GET /miler/earnings?period=weekly` and
/// mapped it into the seven bars. That field is not on the contract: the
/// earnings response carries `completed_stops`, `cancelled_stops`,
/// `total_stops`, `total_kms`, `total_earnings` and `total_bonus` — six
/// **totals for the period asked for**, and no per-day series at all.
///
/// So `breakdown` was always null, the list was always empty, and the chart
/// drew seven bars of nothing while the rider had ridden all week. It failed
/// silently because an absent key and an empty week look identical downstream.
///
/// ── What it does instead ──
///
/// The endpoint answers for *a* period, and it takes a `date`. A week is seven
/// days, so the week is seven daily calls — asked concurrently, so the page
/// waits for the slowest one rather than the sum of seven.
///
/// That is more requests than one, and it is the honest cost of a contract that
/// has no series on it. It is bounded (seven, on a screen opened occasionally
/// rather than polled), and it is built from the same figure the totals row
/// prints, so the bars and the total cannot disagree.
///
/// **The fast path stays.** If `breakdown` ever ships, it is used and the seven
/// calls are skipped — that check is two lines and it is what makes this
/// removable later without touching the page.
/// ─────────────────────────────────────────────────────────────────────────
class RiderWeeklyKmController {
/// `Mon` … `Sun`. Written out rather than taken from `intl` because these
/// strings are matched against the server's own day names elsewhere, and a
/// locale-aware formatter would introduce a mismatch that comparison cannot
/// survive.
static const List<String> _days = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
static String _iso(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
static double _num(dynamic v) {
if (v is num) return v.toDouble();
return double.tryParse('${v ?? ''}') ?? 0;
}
/// Reads a weekly `breakdown` into the chart's series, or returns null when
/// there is nothing usable in it.
///
/// ── Null is the whole contract here ──
///
/// Anything short of a series the chart can draw has to fall through to the
/// seven daily calls, because the alternative is what this page shipped for
/// months: an empty list drawn as seven empty bars, indistinguishable from a
/// week with no riding in it. Absent, null, not a list, empty, entries that
/// are not maps, entries with no day or no parseable distance — all of them
/// are "no series", and none of them is a chart.
///
/// ── The day label is normalised ──
///
/// The chart labels its bars with the first three characters of `day`, which
/// works for `Mon` and produces `202` for `2026-08-19`. The backend's example
/// uses the ISO form, so an ISO date is converted to the weekday it names and
/// anything else is passed through — a server that sends `Monday`, `Mon` or
/// `mon` already works, and one that sends something unrecognisable is
/// unusable rather than silently mislabelled.
static List<RiderWeeklyKms>? _readBreakdown(dynamic raw) {
if (raw is! List || raw.isEmpty) return null;
final rows = <RiderWeeklyKms>[];
for (final entry in raw) {
if (entry is! Map) return null;
final day = _dayLabel(entry['day']);
if (day.isEmpty) return null;
final kms = entry['kms'];
if (kms != null && kms is! num && double.tryParse('$kms') == null) {
return null;
}
rows.add(RiderWeeklyKms(day: day, kms: _num(kms)));
}
return rows.isEmpty ? null : rows;
}
/// `2026-08-19` → `Tue`. Any other non-empty string is returned as it came.
static String _dayLabel(dynamic raw) {
final s = raw?.toString().trim() ?? '';
if (s.isEmpty) return '';
final parsed = DateTime.tryParse(s);
if (parsed != null) return _days[parsed.weekday - 1];
return s;
}
/// The last seven days, oldest first, plus the week's total.
///
/// [userId] is unused: the token identifies the rider, and asking for someone
/// else's kilometres is not a thing the endpoint offers. Kept on the
/// signature because the call sites read better for naming whose distance
/// they mean.
Future<Map<String, dynamic>> getRiderWeeklyKms(int userId) async {
final weekly = await MilerApi.earnings(period: 'weekly');
if (!weekly.ok) {
throw Exception('Failed to fetch (code: ${weekly.status})');
}
final weekTotal = _num(weekly.map['total_kms']);
// ── The fast path ──
//
// If the server returns a per-day series, take it and spend no further
// requests. Nothing below runs.
final fast = _readBreakdown(weekly.map['breakdown']);
if (fast != null) {
return {'details': fast, 'total_kms': weekTotal};
}
// Seven days ending today, asked at once.
final today = DateTime.now();
final dates = <DateTime>[
for (var back = 6; back >= 0; back--)
DateTime(today.year, today.month, today.day - back),
];
final results = await Future.wait(
dates.map((d) => MilerApi.earnings(period: 'daily', date: _iso(d))),
);
final details = <RiderWeeklyKms>[];
var summed = 0.0;
for (final (i, res) in results.indexed) {
// A day that failed is a day with no figure, not a zero worth charting
// against the others — but the bar still has to exist or the week is six
// days long and the labels slide. Zero, and the total below is what
// corrects for it.
final km = res.ok ? _num(res.map['total_kms']) : 0.0;
summed += km;
details.add(RiderWeeklyKms(day: _days[dates[i].weekday - 1], kms: km));
}
return {
'details': details,
// The weekly total is the server's own where it has one — the seven daily
// figures are a reconstruction, and a reconstruction should not overrule
// the number the backend computed. It stands in only when the weekly call
// reported nothing.
'total_kms': weekTotal > 0 ? weekTotal : summed,
};
}
}