Files
doormile_milderapp/lib/data/accepted_store.dart
2026-08-11 13:16:33 +05:30

312 lines
12 KiB
Dart

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<Map<String, dynamic>> _decode(String? raw) {
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
} catch (_) {}
return [];
}
/// All bookings that have been locally accepted (each carries
/// `orderstatus: 'accepted'`).
Future<List<Map<String, dynamic>>> getAcceptedBookings() async {
final prefs = await SharedPreferences.getInstance();
return _decode(prefs.getString(_kAcceptedBookingsKey));
}
/// The set of order ids that have been locally accepted.
Future<Set<String>> 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<Set<String>> getRejectedOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
}
/// Remember the given order ids as rejected.
Future<void> addRejectedOrderIds(List<String> 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<void> removeRejectedOrderIds(List<String> 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<void> removeAcceptedBookings(List<String> 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<List<Map<String, dynamic>>> 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<void> addCompletedBookings(
List<Map<String, dynamic>> bookings, {
bool cancelled = false,
}) async {
if (bookings.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now();
final Map<String, Map<String, dynamic>> 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<String, dynamic>.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<List<Map<String, dynamic>>> 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<Set<String>> 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<void> addSkippedBooking(
Map<String, dynamic> booking, {
String reason = '',
}) async {
final id = (booking['orderid'] ?? '').toString();
if (id.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now();
final Map<String, Map<String, dynamic>> byId = {
for (final b in _decode(prefs.getString(_kSkippedBookingsKey)))
(b['orderid'] ?? '').toString(): b,
};
final copy = Map<String, dynamic>.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<void> removeSkippedBookings(List<String> 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<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
if (bookings.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final Map<String, Map<String, dynamic>> 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<String, dynamic>.from(b);
copy['orderstatus'] = 'accepted';
byId[id] = copy;
}
await prefs.setString(
_kAcceptedBookingsKey,
jsonEncode(byId.values.toList()),
);
}