diet-guard/app/lib/services/kuchnia_spread.dart
Krzysztof kuhy Rudnicki 8905d072d0 feat: carry the catering credential to the phone
The Dart half of the credential sync: a `KuchniaCredentialService` that owns
the value locally, a `sync_merge_kuchnia.dart` adapter mirroring the Python
one, and a `_syncKuchniaCredential` tick alongside the banks and budget. The
phone can now receive the panel password, which is the prerequisite for it
fetching anything itself.

One source of truth, per the answered question: the value lives in the synced
document store and nowhere else. Deliberately *not* mirrored into
`flutter_secure_storage` -- two homes for one value is how a stale password
survives a change made on the other device, and the credential has to be
readable synchronously at merge time anyway.

Gated by a second shared fixture, `tests/fixtures/kuchnia_credential.json`,
asserted by both suites. Two cases carry the weight:

- an **unparsable** edit time must fall back to the same clock on both sides
  (Python catches `ValueError` from `fromisoformat`, Dart's `tryParse` returns
  null). Verified they agree on `wall_time_ms == 0`; mutating that expectation
  fails both suites.
- a **blank half** contributes nothing, so a half-filled settings form cannot
  push an empty-string password that wins LWW against a real one.

`KuchniaCredentialService.init()` is called in the WorkManager isolate as well
as `main()`. A fresh isolate has its own static state and `_syncKuchniaCredential`
no-ops when the singleton is missing, so without it a background tick would
neither publish this device's credential nor receive a peer's -- and on the
phone the background tick is most of the syncing there is. Same class of trap
as the `initSyncDeviceId()` one already documented there.

`.toDouble()` came off the bank record: the fields are already `double`, so it
was a no-op the analyzer flags. The field *types* are what keep the encoding
byte-identical to Python's, and `kuchnia_parity_test.dart` asserts that.

654 Dart tests, 1040 Python tests, 100% Python branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
2026-08-23 08:25:27 +02:00

87 lines
3.5 KiB
Dart

/// Assigns catering dishes to meal slots, in the provider's own order.
///
/// The Dart mirror of `diet_guard/_kuchnia_spread.py`.
///
/// The panel returns each dish with a `mealPriority` (1..N: Śniadanie, II
/// śniadanie, Obiad, Podwieczorek, Kolacja) -- the order the catering intends
/// them to be eaten in. That beats inferring an order from the dish names, and
/// it beats spreading them arithmetically.
///
/// Counts rarely match: a 5-meal plan against the default 4 slots means two
/// dishes share the first slot. The mapping is `i * S ~/ N`, which is
/// monotonic non-decreasing, keeps the first dish on the first slot and the
/// last on the last, and doubles up the earliest slots rather than dropping
/// anything.
///
/// **Integer `~/` only, never `round()`.** Repo convention for slot arithmetic
/// (`docs/meal-schedule.md`): Python's `round` is banker's and Dart's is
/// half-away-from-zero, so a float path silently desyncs the two devices --
/// and a slot one device offers while the other does not is a checkpoint that
/// can never be satisfied, i.e. a permanent lock. `index`, `span` and `count`
/// are all `int`, so `~/` matches Python's `//` exactly (`~/` on doubles
/// truncates toward zero where `//` floors).
///
/// KEEP IN SYNC WITH `diet_guard/_kuchnia_spread.py`, gated by
/// `tests/fixtures/kuchnia_day.json`.
library;
import 'package:diet_guard_app/models/kuchnia_dish.dart';
/// A dish paired with the meal-slot hour it was assigned to.
class SlottedDish {
/// Pairs [dish] with the [slot] hour it should be logged against.
const SlottedDish({required this.dish, required this.slot});
/// The dish itself.
final KuchniaDish dish;
/// The slot hour, e.g. `12` for the 12:00 checkpoint.
final int slot;
}
/// Pairs each dish with a slot hour, following the provider's meal order.
///
/// Returns an empty list when either input is empty -- a day with no delivery
/// and a schedule with no slots are both "nothing to assign", not errors.
List<SlottedDish> assignSlots(List<KuchniaDish> dishes, List<int> slots) {
if (dishes.isEmpty || slots.isEmpty) return const [];
// Ties fall back to name so the result is deterministic; a bank import that
// reshuffled between runs would look like a change and re-stamp every
// record.
//
// The payload index is the final tiebreak because `List.sort` is *not*
// stable in Dart while Python's `sorted` is: two dishes sharing both
// priority and name would otherwise be free to swap here and not on the PC.
// With the index in the comparator the ordering is total, so both languages
// agree without relying on either sort's stability.
final indexed = <(int, KuchniaDish)>[
for (var i = 0; i < dishes.length; i++) (i, dishes[i]),
]..sort((a, b) {
final byPriority = a.$2.priority.compareTo(b.$2.priority);
if (byPriority != 0) return byPriority;
final byName = a.$2.name.compareTo(b.$2.name);
if (byName != 0) return byName;
return a.$1.compareTo(b.$1);
});
final count = indexed.length;
final span = slots.length;
return [
for (var index = 0; index < count; index++)
SlottedDish(
dish: indexed[index].$2,
slot: slots[index * span ~/ count],
),
];
}
/// Returns [dishes] ordered by the provider's own meal priority.
///
/// So the dish offered for the slot being filled is the one the catering
/// actually intends for it, rather than whatever order the payload arrived in.
List<KuchniaDish> dishesInSlotOrder(
List<KuchniaDish> dishes,
List<int> slots,
) => [for (final item in assignSlots(dishes, slots)) item.dish];