312 lines
12 KiB
Dart
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()),
|
|
);
|
|
}
|