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 _cache = {}; 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 _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 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 _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 buildIndex(List rows) { final index = {}; final tookActionable = {}; for (final row in rows.whereType()) { 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() ?? ''); } }