diet-guard/app/lib/services/kuchnia_queue.dart
Krzysztof kuhy Rudnicki 11173c7088
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
kuchnia: walk every active order, not just the first
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 12:32:17 +02:00

148 lines
6.0 KiB
Dart

/// The day's delivered dishes, offered to the log form one at a time.
///
/// **A delivered meal is not an eaten meal.** This queue only ever *prefills*
/// the form; the user still taps to log each dish. Nothing here writes a log
/// entry, and nothing here may start doing so -- the gate would then satisfy
/// its own checkpoint from a delivery note, and the log would record what the
/// courier dropped off rather than what was eaten.
///
/// Two things are load-bearing:
///
/// * **The queue survives a submit.** After a dish is logged the next one is
/// already in the form, so one tap walks the whole delivery. On the PC this
/// regressed once into a dead letter -- every dish after the first stayed
/// queued behind another click, and because the refresh there is unguarded
/// that meant a fresh login-plus-three-requests *per dish*.
/// * **The automatic refresh is guarded** by a device-local "already fetched
/// today" marker, mirroring Python's `refresh_delivery_once`. Without it
/// every logged meal would pay a login plus three requests against a third
/// party. Only a *clean* fetch records the day, so an outage is retried.
/// The settings button deliberately does **not** go through this: an
/// explicit ask always goes and looks, matching `diet-guard kuchnia`.
library;
import 'package:diet_guard_app/models/kuchnia_dish.dart';
import 'package:diet_guard_app/services/document_store.dart';
import 'package:diet_guard_app/services/document_store_factory.dart';
import 'package:diet_guard_app/services/kuchnia_import.dart';
import 'package:diet_guard_app/services/kuchnia_orders.dart' show isoDay;
import 'package:flutter/foundation.dart';
/// Holds today's delivered dishes and hands them out in the caterer's order.
class KuchniaQueueService {
KuchniaQueueService._(this._store);
/// Document holding the last fetched day, one ISO date.
///
/// Device-local on purpose: this is a rate limit, not shared state. Syncing
/// it would let one device's fetch suppress another's.
static const documentName = 'kuchnia_last_import.json';
static KuchniaQueueService? _instance;
/// Returns the initialized singleton; throws if [init] was not called.
static KuchniaQueueService get instance => _instance!;
/// True once initialised, so callers can skip work in widget tests.
static bool get isInitialized => _instance != null;
final DocumentStore _store;
final List<KuchniaDish> _pending = [];
String _lastImportDay = '';
/// The dishes not yet logged, in the caterer's own meal order.
static List<KuchniaDish> get pending =>
List.unmodifiable(_instance?._pending ?? const []);
/// The next dish to offer, or null when the queue is empty.
static KuchniaDish? get next =>
(_instance?._pending.isEmpty ?? true) ? null : _instance!._pending.first;
/// How many dishes are still waiting to be logged.
static int get remaining => _instance?._pending.length ?? 0;
/// Initialises the singleton against the platform document store.
///
/// Without this the queue silently no-ops on a real device: every entry
/// point checks [isInitialized] first, so a missing call reads as "no
/// delivery" rather than as an error.
static Future<KuchniaQueueService> init() async {
if (_instance != null) return _instance!;
// Resolving the platform store is a plugin call, not reachable from
// `flutter test`.
// coverage:ignore-start
final service = KuchniaQueueService._(await openDocumentStore());
// coverage:ignore-end
await service._load();
_instance = service;
return service;
}
/// Initialises from [store], for tests.
@visibleForTesting
static Future<KuchniaQueueService> initForTesting(DocumentStore store) async {
final service = KuchniaQueueService._(store);
await service._load();
_instance = service;
return service;
}
/// Resets the singleton so [initForTesting] can run again.
@visibleForTesting
static void resetForTesting() => _instance = null;
Future<void> _load() async {
final raw = await _store.read(documentName);
if (raw != null) _lastImportDay = raw.trim();
}
/// True when [day]'s delivery has already been fetched on this device.
bool alreadyFetched(DateTime day) => _lastImportDay == isoDay(day);
/// Records that [day]'s delivery was fetched cleanly.
///
/// Best-effort: this is a rate limit, not state anyone depends on. Failing
/// to write it costs one extra fetch, which is strictly better than failing
/// an import that already succeeded.
Future<void> recordFetched(DateTime day) async {
_lastImportDay = isoDay(day);
try {
await _store.write(documentName, _lastImportDay);
} on Exception {
// Ignore: an unrecorded fetch just means we look again.
}
}
/// Replaces the queue with [dishes], dropping any already logged today.
void offer(List<KuchniaDish> dishes, {Set<String> alreadyLogged = const {}}) {
_pending
..clear()
..addAll(
dishes.where((dish) => !alreadyLogged.contains(dish.bankKey)),
);
}
/// Removes [dish] from the queue once it has actually been logged.
void markLogged(KuchniaDish dish) =>
_pending.removeWhere((queued) => queued.bankKey == dish.bankKey);
/// Empties the queue, e.g. when the day rolls over.
void clear() => _pending.clear();
/// Fetches [day]'s delivery unless it has already been fetched today.
///
/// Returns an empty, successful result when the day was already fetched --
/// "nothing new", not an error. This is the entry point every *automatic*
/// trigger uses; the settings button calls [refreshDelivery] directly.
static Future<KuchniaRefresh> refreshOnce(DateTime day) async {
final service = _instance;
if (service == null) return const KuchniaRefresh();
if (service.alreadyFetched(day)) return const KuchniaRefresh();
final result = await refreshDelivery(day);
// Only a fetch that found dishes counts: an outage, or a walk that missed
// the delivery, must not suppress the day's retries.
if (result.ok && result.dishes.isNotEmpty) await service.recordFetched(day);
return result;
}
}