import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; /// Persistent store of bookings the rider has accepted while the app runs on /// mock data (offline / demo mode). This lets the accept flow be functional /// without a server: the Home queue drops accepted bookings (and keeps them /// dropped across refetches/navigation), and the Bookings tab picks them up. const String _kAcceptedBookingsKey = 'mock_accepted_bookings'; const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids'; /// Stops finished today, for the Activity tab. /// /// ── Why a local store and not just the API ── /// /// Finishing a stop is optimistic everywhere in this app: the confirm sheet /// records the completion and moves the rider on whether or not the status call /// succeeded, because a rider who watches a completed stop bounce back stops /// trusting the button. That leaves the completion existing *only* in the /// Bookings tab's in-memory state — it is dropped from the working list and /// removed from the accepted store, and nothing else remembers it. /// /// So Activity, which reads the queue endpoints, showed nothing at all: on demo /// data the backend never returns the finished stop, and on live data it does /// not return it until the write lands and the next poll comes round. The /// rider finished a booking and the tab meant to prove it stayed empty. /// /// This is the record that survives that. It is the same trade the accepted /// store already makes, for the same reason. const String _kCompletedBookingsKey = 'completed_bookings'; /// Stops the rider parked mid-shift for a return visit. /// /// ── Why these are stored at all ── /// /// A skip used to live in one place only: `_MyPickupsState._skippedOrdersCache`, /// a static map in the Bookings screen's State. That was enough while the skip /// was displayed on Home, because Home is rebuilt from the same queue the cache /// re-stamps. It is not enough now that a skipped stop is shown on **Activity**, /// which reads its own endpoints: on demo data the backend never reports the /// skip at all, and on live data it does not report it until the write lands. /// The rider would skip a stop and find the tab that is meant to account for it /// empty — the same failure the completed store was written to fix. /// /// So a skip is recorded here the moment it is taken, with the reason the rider /// gave, and it is removed when he resumes the stop. Same trade as /// [_kCompletedBookingsKey], for the same reason. const String _kSkippedBookingsKey = 'skipped_bookings'; List> _decode(String? raw) { if (raw == null || raw.isEmpty) return []; try { final decoded = jsonDecode(raw); if (decoded is List) { return decoded .whereType() .map((e) => Map.from(e)) .toList(); } } catch (_) {} return []; } /// All bookings that have been locally accepted (each carries /// `orderstatus: 'accepted'`). Future>> getAcceptedBookings() async { final prefs = await SharedPreferences.getInstance(); return _decode(prefs.getString(_kAcceptedBookingsKey)); } /// The set of order ids that have been locally accepted. Future> getAcceptedOrderIds() async { final list = await getAcceptedBookings(); return list .map((b) => (b['orderid'] ?? '').toString()) .where((s) => s.isNotEmpty) .toSet(); } /// Order ids the rider has locally rejected. Home filters these out so a /// rejected booking leaves the pending list. Future> getRejectedOrderIds() async { final prefs = await SharedPreferences.getInstance(); return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet(); } /// Remember the given order ids as rejected. Future addRejectedOrderIds(List ids) async { final clean = ids.where((s) => s.isNotEmpty).toSet(); if (clean.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final existing = (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet(); existing.addAll(clean); await prefs.setStringList(_kRejectedOrderIdsKey, existing.toList()); } /// Undo a rejection. /// /// Rejecting is one tap on a moving bike, so it is genuinely easy to hit the /// wrong stop. Without this the mistake is permanent for the rest of the shift /// and the rider has to call the hub to fix it. Future removeRejectedOrderIds(List ids) async { final drop = ids.where((s) => s.isNotEmpty).toSet(); if (drop.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final remaining = (prefs.getStringList(_kRejectedOrderIdsKey) ?? []) .where((id) => !drop.contains(id)) .toList(); await prefs.setStringList(_kRejectedOrderIdsKey, remaining); } /// Remove the given order ids from the accepted store. Called when a pickup is /// completed or cancelled so it stops counting as an open booking (and no longer /// reappears on the Bookings tab or as a "next stop" after a rebuild). Future removeAcceptedBookings(List ids) async { final drop = ids.where((s) => s.isNotEmpty).toSet(); if (drop.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final remaining = _decode( prefs.getString(_kAcceptedBookingsKey), ).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList(); await prefs.setString(_kAcceptedBookingsKey, jsonEncode(remaining)); } /// Stops the rider finished **today**, newest first. /// /// Entries from earlier days are dropped on read rather than on a timer, so a /// shift that runs past midnight keeps its stops until the rider next opens the /// tab — and the store cannot grow without bound. Future>> getCompletedBookings() async { final prefs = await SharedPreferences.getInstance(); final all = _decode(prefs.getString(_kCompletedBookingsKey)); final today = _dayStamp(DateTime.now()); final mine = all.where((b) => (b['completedday'] ?? '') == today).toList() ..sort( (a, b) => (b['completedat'] ?? '').toString().compareTo( (a['completedat'] ?? '').toString(), ), ); // Prune in the background if yesterday's rows are still in there. if (mine.length != all.length) { await prefs.setString(_kCompletedBookingsKey, jsonEncode(mine)); } return mine; } /// Prefs key holding the moment the rider opened a stop and set off for it. /// Written by the stop map screen, read and cleared here. See [kStopStartedKey]. String kStopStartedKey(Object pickupId) => 'stop_started_$pickupId'; /// Prefs key holding the moment the rider confirmed he was at the door. /// Written by the stop map screen when it starts the pickup. String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId'; /// Record a finished stop. [cancelled] separates "could not complete" from a /// clean pickup/delivery — Activity shows both, worded differently. /// /// ── Why the timings are collected here ── /// /// A finished stop is a record, and the questions asked of it afterwards are /// all about *time*: when did he set off, when did he get there, how long did /// the door take, did he beat the ETA. Every one of those was being measured /// somewhere in the app and then dropped on the floor — the two timestamps live /// in SharedPreferences keys that the next stop overwrites, so by the time /// anyone opened Activity they were gone or, worse, belonged to a different /// stop. /// /// This is the last moment they are all true at once, so this is where they are /// written down and the keys are cleared. Same argument as [StopCompliance], /// which is stamped one call earlier for the same reason. Future addCompletedBookings( List> bookings, { bool cancelled = false, }) async { if (bookings.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final now = DateTime.now(); final Map> byId = { for (final b in _decode(prefs.getString(_kCompletedBookingsKey))) (b['orderid'] ?? '').toString(): b, }; for (final b in bookings) { final id = (b['orderid'] ?? '').toString(); if (id.isEmpty) continue; final copy = Map.from(b); // Stamped so the same `stopStatusOf` test that drops a stop from Bookings // is the one that picks it up here — the two can never disagree about what // "done" means. copy['orderstatus'] = cancelled ? 'cancelled' : 'picked'; copy['completedat'] = now.toIso8601String(); copy['completedday'] = _dayStamp(now); final pickupId = (b['pickupid'] ?? '').toString(); if (pickupId.isNotEmpty) { final started = prefs.getString(kStopStartedKey(pickupId)); final arrived = prefs.getString(kStopArrivedKey(pickupId)); if (started != null && started.isNotEmpty) copy['startedat'] = started; if (arrived != null && arrived.isNotEmpty) copy['arrivedat'] = arrived; // Cleared, or a stop worked twice in a day (a resumed skip) reports the // first attempt's clock against the second attempt's completion. await prefs.remove(kStopStartedKey(pickupId)); await prefs.remove(kStopArrivedKey(pickupId)); } byId[id] = copy; } await prefs.setString( _kCompletedBookingsKey, jsonEncode(byId.values.toList()), ); } /// Stops skipped **today**, newest first. /// /// Pruned on read like [getCompletedBookings]: a skip is a "come back to it /// this shift" marker, and one left over from yesterday is noise the rider can /// no longer act on. Future>> getSkippedBookings() async { final prefs = await SharedPreferences.getInstance(); final all = _decode(prefs.getString(_kSkippedBookingsKey)); final today = _dayStamp(DateTime.now()); final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList() ..sort( (a, b) => (b['skippedat'] ?? '').toString().compareTo( (a['skippedat'] ?? '').toString(), ), ); if (mine.length != all.length) { await prefs.setString(_kSkippedBookingsKey, jsonEncode(mine)); } return mine; } /// The set of order ids currently parked as skipped. Future> getSkippedOrderIds() async { final list = await getSkippedBookings(); return list .map((b) => (b['orderid'] ?? '').toString()) .where((s) => s.isNotEmpty) .toSet(); } /// Record a skip, with the reason the rider picked in the skip sheet. Future addSkippedBooking( Map booking, { String reason = '', }) async { final id = (booking['orderid'] ?? '').toString(); if (id.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final now = DateTime.now(); final Map> byId = { for (final b in _decode(prefs.getString(_kSkippedBookingsKey))) (b['orderid'] ?? '').toString(): b, }; final copy = Map.from(booking); // Stamped so `stopStatusOf` reads it as skipped wherever it surfaces — the // same trick the completed store uses, so one status test serves both. copy['orderstatus'] = 'skipped'; copy['skipreason'] = reason; copy['skippedat'] = now.toIso8601String(); copy['skippedday'] = _dayStamp(now); byId[id] = copy; await prefs.setString(_kSkippedBookingsKey, jsonEncode(byId.values.toList())); } /// Forget a skip — the rider resumed the stop, so it is live work again. Future removeSkippedBookings(List ids) async { final drop = ids.where((s) => s.isNotEmpty).toSet(); if (drop.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final remaining = _decode( prefs.getString(_kSkippedBookingsKey), ).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList(); await prefs.setString(_kSkippedBookingsKey, jsonEncode(remaining)); } String _dayStamp(DateTime t) => '${t.year}-${t.month.toString().padLeft(2, '0')}-' '${t.day.toString().padLeft(2, '0')}'; /// Persist the given bookings as accepted, deduped by `orderid`. Future addAcceptedBookings(List> bookings) async { if (bookings.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final Map> byId = { for (final b in _decode(prefs.getString(_kAcceptedBookingsKey))) (b['orderid'] ?? '').toString(): b, }; for (final b in bookings) { final id = (b['orderid'] ?? '').toString(); if (id.isEmpty) continue; final copy = Map.from(b); copy['orderstatus'] = 'accepted'; byId[id] = copy; } await prefs.setString( _kAcceptedBookingsKey, jsonEncode(byId.values.toList()), ); }