first commit
This commit is contained in:
139
lib/data/assignment_lookup.dart
Normal file
139
lib/data/assignment_lookup.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:miler/data/api_config.dart';
|
||||
|
||||
/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject
|
||||
/// endpoints key on.
|
||||
///
|
||||
/// Why this exists: `POST /miler/assignments/{id}/accept` looks the row up by
|
||||
/// `bookingassignmentid`, but everything the app renders comes from
|
||||
/// `GET /miler/bookings`, whose rows only carry `bookingid`. Those two ids come
|
||||
/// from different sequences and do not match, so sending a booking id makes the
|
||||
/// backend answer 404 "assignment not found" — the rider taps Accept, sees
|
||||
/// success, and the booking is never actually accepted server-side.
|
||||
///
|
||||
/// `GET /miler/assignments` is the only place the pairing is exposed, so we
|
||||
/// fetch it and keep a short-lived bookingid -> bookingassignmentid map.
|
||||
class AssignmentLookup {
|
||||
AssignmentLookup._();
|
||||
|
||||
/// bookingid (as string) -> bookingassignmentid
|
||||
static final Map<String, int> _cache = <String, int>{};
|
||||
static DateTime? _fetchedAt;
|
||||
|
||||
/// Assignments change whenever the hub assigns work, so the map goes stale
|
||||
/// quickly. Short enough to pick up new work, long enough that a burst of
|
||||
/// accepts doesn't refetch per tap.
|
||||
static const Duration _ttl = Duration(seconds: 30);
|
||||
|
||||
/// Assignment states that are still actionable by the rider. A booking can
|
||||
/// legitimately have several assignment rows over its life (assign → reject →
|
||||
/// reassign), so when more than one exists we want the live one, not the
|
||||
/// rejected corpse.
|
||||
static const Set<String> _actionable = {'Assigned', 'Accepted'};
|
||||
|
||||
static bool get _isStale {
|
||||
final at = _fetchedAt;
|
||||
return at == null || DateTime.now().difference(at) > _ttl;
|
||||
}
|
||||
|
||||
static void invalidate() {
|
||||
_cache.clear();
|
||||
_fetchedAt = null;
|
||||
}
|
||||
|
||||
/// The assignment id for [bookingId], or null when the backend has no
|
||||
/// assignment row for it (or the call fails).
|
||||
///
|
||||
/// Refetches once on a cache miss before giving up: a booking the hub just
|
||||
/// assigned will not be in a cache populated moments earlier, and that is
|
||||
/// exactly the case the rider hits when accepting fresh work.
|
||||
static Future<int?> idForBooking(dynamic bookingId) async {
|
||||
final String key = bookingId?.toString().trim() ?? '';
|
||||
if (key.isEmpty) return null;
|
||||
|
||||
if (_isStale) {
|
||||
await _refresh();
|
||||
}
|
||||
final cached = _cache[key];
|
||||
if (cached != null) return cached;
|
||||
|
||||
// Miss against a warm cache — the assignment may have been created since.
|
||||
await _refresh();
|
||||
return _cache[key];
|
||||
}
|
||||
|
||||
static Future<void> _refresh() async {
|
||||
try {
|
||||
final uri = Uri.parse(ApiConfig.url('/miler/assignments'));
|
||||
final res = await http
|
||||
.get(uri, headers: await ApiConfig.authHeaders())
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.statusCode}');
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = json.decode(res.body);
|
||||
dynamic data = decoded;
|
||||
if (decoded is Map) {
|
||||
data = decoded['data'] ?? decoded['details'] ?? decoded['assignments'];
|
||||
}
|
||||
if (data is! List) {
|
||||
debugPrint('[ASSIGNMENTS] unexpected payload: ${data.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
final next = buildIndex(data);
|
||||
_cache
|
||||
..clear()
|
||||
..addAll(next);
|
||||
_fetchedAt = DateTime.now();
|
||||
debugPrint(
|
||||
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ASSIGNMENTS] fetch error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses the assignment rows into one bookingid -> bookingassignmentid
|
||||
/// entry per booking.
|
||||
///
|
||||
/// The backend orders by `assignedat DESC`, so the first row seen for a
|
||||
/// booking is its newest. Keep that one, but let an actionable row override a
|
||||
/// newer terminal one — a booking that was assigned, rejected, then
|
||||
/// reassigned must resolve to the live assignment, not the rejected corpse.
|
||||
@visibleForTesting
|
||||
static Map<String, int> buildIndex(List<dynamic> rows) {
|
||||
final index = <String, int>{};
|
||||
final tookActionable = <String>{};
|
||||
|
||||
for (final row in rows.whereType<Map>()) {
|
||||
final bookingKey = row['bookingid']?.toString().trim() ?? '';
|
||||
final assignmentId = _asInt(row['bookingassignmentid']);
|
||||
if (bookingKey.isEmpty || assignmentId == null) continue;
|
||||
|
||||
final status = (row['assignmentstatus'] ?? '').toString().trim();
|
||||
final isActionable = _actionable.contains(status);
|
||||
|
||||
if (!index.containsKey(bookingKey)) {
|
||||
index[bookingKey] = assignmentId;
|
||||
if (isActionable) tookActionable.add(bookingKey);
|
||||
} else if (isActionable && !tookActionable.contains(bookingKey)) {
|
||||
index[bookingKey] = assignmentId;
|
||||
tookActionable.add(bookingKey);
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
static int? _asInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse(v?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user