import 'dart:async'; import 'package:flutter/foundation.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ONE PRESS, ONE REQUEST /// /// Every business-critical action in this app is a POST that the backend is not /// idempotent about: `accept`, `reject`, `reached`, `parcel`, `payment`, /// `pickup-complete`, `deliver`, `skip`, `duty/start`, `duty/end`, /// `breaks/start`, `breaks/end`, `availability`. /// /// A rider presses those on a moving bike, with gloves, on a screen he cannot /// look at for long — so a double tap is not an edge case, it is Tuesday. And /// the failure is not cosmetic: two `duty/start` calls are an error the server /// rejects, two `payment` calls are two payments, and two `accept` calls race /// each other to decide what the list shows next. /// /// The screens each solved this, or did not, with their own `_busy` flag — /// which works until the widget rebuilds, the callback is captured twice, or the /// action is reachable from two places (a card and a sheet) that do not share a /// flag. This is one place that solves it once, keyed by the thing being /// mutated rather than by the widget that happens to be showing it. /// /// ── What this is not ── /// /// Not a queue and not a retry. A second press while the first is in flight is /// **dropped**, not deferred: the rider meant one thing, and running it twice a /// second later is the bug, not the fix. Not a cache either — once the call /// completes the key is free, so a genuine second accept of the same stop after /// a failure still goes through. /// ───────────────────────────────────────────────────────────────────────── class MutationGuard { MutationGuard._(); /// Keys with a request currently in flight. static final Set _inFlight = {}; /// True while [key] has a mutation running. /// /// Read by buttons so they can show the disabled/loading state that makes the /// dropped second press *visible* rather than merely harmless. static bool isBusy(String key) => _inFlight.contains(key); /// Runs [action] unless [key] is already running, in which case it returns /// null and does nothing. /// /// The key names the **resource and the verb**, not the screen: /// `accept:1042`, `deliver:77`, `duty`. Two widgets showing the same stop /// therefore share one guard, which is the whole point. static Future run(String key, Future Function() action) async { if (_inFlight.contains(key)) { debugPrint('[GUARD] dropped duplicate "$key" — one is already running'); return null; } _inFlight.add(key); try { return await action(); } finally { // `finally`, so a thrown request frees its key. A guard that leaks on // failure is worse than no guard: the rider's retry would be silently // dropped for the rest of the session. _inFlight.remove(key); } } /// Clears every key. For tests, and for sign-out — a new session must not /// inherit the last one's in-flight set. static void reset() => _inFlight.clear(); }