feat: JSON export/import on device

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 20:12:37 +01:00
parent e75e2bc89a
commit 1b5e201136
18 changed files with 1355 additions and 70 deletions

View File

@ -1,11 +1,6 @@
/// App settings: kcal goal, notifications, and links to the two sync
/// surfaces.
///
/// "Sync settings" is the shared `sync_settings_ui` package (Firebase
/// only -- diet_guard has no local backup format, so `BackupSlot` is null).
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]) because
/// connecting it also triggers a real log sync via `runSync`, which the
/// shared package's Firebase section does not do -- it only saves settings.
/// App settings: kcal goal, notifications, data export/import, and links to
/// the shared Firebase "Sync settings" and the app-local GitHub mirror (its
/// connect also runs a real `runSync`, which the shared screen does not).
library;
import 'dart:async';
@ -21,6 +16,7 @@ import 'package:diet_guard_app/services/firebase_client.dart';
import 'package:diet_guard_app/services/google_sign_in_backend.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/data_backup_row.dart';
import 'package:diet_guard_app/widgets/meal_schedule_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -203,6 +199,11 @@ class _SettingsScreenState extends State<SettingsScreen>
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openGitHubMirror()),
),
DataBackupRow(
onImported: () => _kcalGoalController.text = AppSettingsService
.dailyKcalGoal
.toString(),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),

View File

@ -0,0 +1,144 @@
/// User-owned export and merge-only import of every synced document.
///
/// Import is `mergeLogs` plus the sync tick's own write-back, so it never
/// deletes and a repeat is a no-op. The catering login (a password) travels
/// only as an AEAD-sealed `kuchnia` section; see `data_export_kuchnia.dart`.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/services/data_export_kuchnia.dart';
import 'package:diet_guard_app/services/export_keyring.dart';
import 'package:diet_guard_app/services/export_keyring_default.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_budget_local.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:diet_guard_app/services/sync_merge_body.dart';
/// The `format` marker an export carries.
const exportFormat = 'diet_guard_export';
/// The newest export layout this build reads and writes.
const exportVersion = 1;
class _Document {
const _Document(this.key, this.readLocal, this.apply);
final String key;
final Future<Log> Function() readLocal;
final Future<void> Function(Log merged) apply;
}
Future<void> _applyFoodLog(Log merged) async {
final log = logToDayLog(merged);
await LogStorageService.instance.writeLog(log);
await FoodBankService.instance.rebuildAndPersist(log);
}
Future<Log> _readBody() async =>
BodyService.isInitialized ? bodyToLog(BodyService.instance.document) : {};
Future<void> _applyBody(Log merged) async {
if (merged.isEmpty || !BodyService.isInitialized) return;
await BodyService.instance.applyMerged(logToBody(merged));
}
/// Food log first: applying it rebuilds the derived bank merged next.
final List<_Document> _documents = [
_Document(
'food_log',
() async => dayLogToLog(await LogStorageService.instance.readLog()),
_applyFoodLog,
),
_Document(
'food_bank',
() async => foodBankToLog(await FoodBankService.instance.readBank()),
(merged) => FoodBankService.instance.writeBank(logToFoodBank(merged)),
),
_Document(
'food_bank_manual',
() async =>
manualBankToLog(await FoodBankService.instance.readManualBank()),
(merged) =>
FoodBankService.instance.applyMergedManualBank(logToManualBank(merged)),
),
_Document('budget', () async => localBudgetLog(), applyBudgetLog),
const _Document('body', _readBody, _applyBody),
];
Map<String, Object?> _encodeLog(Log log) => {
for (final entry in log.entries) entry.key: entry.value.toJson(),
};
Log _decodeLog(String key, Object? payload) {
if (payload is! Map) throw FormatException('"$key" is not an object');
try {
return {
for (final entry in payload.entries)
entry.key as String: Record.fromJson(
(entry.value as Map).cast<String, dynamic>(),
),
};
} on Object catch (error) {
throw FormatException('"$key" has a malformed record: $error');
}
}
/// Returns every synced document as one pretty-printed JSON export.
///
/// The encrypted login section uses the newest key of [keyring] (default: the
/// OS-keystore ring).
Future<String> exportAllData({DateTime? now, ExportKeyring? keyring}) async {
final documents = <String, Object?>{
for (final doc in _documents) doc.key: _encodeLog(await doc.readLocal()),
};
return const JsonEncoder.withIndent(' ').convert({
'format': exportFormat,
'version': exportVersion,
'exportedAt': (now ?? DateTime.now()).toIso8601String(),
'documents': documents,
...await kuchniaExportEntries(keyring ?? defaultKeyring()),
});
}
/// Merges an [exportAllData] payload into the local store.
///
/// Parses every document before writing any, so a bad file changes nothing.
/// Throws [FormatException] when [text] is not a diet_guard export. A sealed
/// login that cannot be opened (unknown key id even after [pullKeys], or
/// tampered data) is skipped after the rest merged, then reported as
/// [KuchniaImportException]. An existing local login is kept.
Future<void> importAllData(
String text, {
ExportKeyring? keyring,
Future<void> Function() pullKeys = pullExportKeys,
}) async {
final Object? raw;
try {
raw = jsonDecode(text);
} on FormatException {
throw const FormatException('not valid JSON');
}
if (raw is! Map || raw['format'] != exportFormat) {
throw const FormatException('not a diet_guard export');
}
final version = raw['version'];
final documents = raw['documents'];
if (version is! int || version > exportVersion || documents is! Map) {
throw const FormatException('unsupported diet_guard export');
}
final incoming = <_Document, Log>{
for (final doc in _documents)
if (documents[doc.key] != null)
doc: _decodeLog(doc.key, documents[doc.key]),
};
final before = {for (final doc in incoming.keys) doc: await doc.readLocal()};
for (final MapEntry(key: doc, value: log) in incoming.entries) {
final local = mergeLogs(before[doc]!, await doc.readLocal());
await doc.apply(mergeLogs(local, log));
}
await importKuchnia(raw, keyring ?? defaultKeyring(), pullKeys);
}

View File

@ -0,0 +1,107 @@
/// The catering login inside the data export, as an encrypted section.
///
/// The password is never written in clear: the whole credential document is
/// sealed with AES-256-GCM under the newest key of the export keyring (see
/// `export_keyring.dart`), and the section names that key by `kid`. If the
/// keystore is unavailable the section is omitted and `kuchnia_skipped` says
/// so; there is no plaintext fallback.
library;
import 'dart:convert';
import 'package:diet_guard_app/services/export_crypto.dart';
import 'package:diet_guard_app/services/export_keyring.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
/// Export key holding the sealed login.
const kuchniaKey = 'kuchnia';
/// Export key explaining why [kuchniaKey] is absent.
const kuchniaSkippedKey = 'kuchnia_skipped';
/// A login in the file could not be restored; the rest of the import did run.
class KuchniaImportException implements Exception {
/// Creates the exception; [reason] is `unknown_key` or `unreadable`.
const KuchniaImportException(this.reason);
/// Why the section was skipped.
final String reason;
@override
String toString() => 'KuchniaImportException: $reason';
}
/// Top-level export entries for the login: the sealed box, or the skip flag.
Future<Map<String, Object?>> kuchniaExportEntries(ExportKeyring keyring) async {
if (!KuchniaCredentialService.isInitialized ||
!KuchniaCredentialService.isConfigured) {
return {};
}
final ExportKey key;
try {
key = await keyring.ensureNewest();
} on Object {
return {kuchniaSkippedKey: 'keystore_unavailable'};
}
final clear = jsonEncode({
'username': KuchniaCredentialService.username,
'password': KuchniaCredentialService.password,
't': KuchniaCredentialService.editedAt,
});
return {kuchniaKey: await encryptWithKey(clear, key)};
}
/// The key for [kid]; pulls the synced keyring once if it is not held yet.
Future<ExportKey?> _findKey(
ExportKeyring keyring,
String kid,
Future<void> Function() pullKeys,
) async {
final held = await keyring.byId(kid);
if (held != null) return held;
try {
await pullKeys();
} on Object {
return null;
}
final pulled = await keyring.byId(kid);
return pulled;
}
/// Opens the section from [root] and writes it only into an empty login.
///
/// Throws [KuchniaImportException] when it is present but cannot be opened.
/// A configured local login is never overwritten.
Future<void> importKuchnia(
Map<dynamic, dynamic> root,
ExportKeyring keyring,
Future<void> Function() pullKeys,
) async {
final box = root[kuchniaKey];
if (box == null) return;
final kid = envelopeKeyId(box);
final Map<dynamic, dynamic> doc;
try {
final key = kid == null ? null : await _findKey(keyring, kid, pullKeys);
if (key == null) throw const KuchniaImportException('unknown_key');
doc = jsonDecode(await decryptWithKey(box, key)) as Map;
} on KuchniaImportException {
rethrow;
} on Object {
throw const KuchniaImportException('unreadable');
}
final username = doc['username'];
final password = doc['password'];
if (username is! String || password is! String) {
throw const KuchniaImportException('unreadable');
}
if (!KuchniaCredentialService.isInitialized ||
KuchniaCredentialService.isConfigured) {
return;
}
await KuchniaCredentialService.instance.applySynced(
username,
password,
doc['t'] is String ? doc['t'] as String : '',
);
}

View File

@ -0,0 +1,100 @@
/// AES-256-GCM sealing for the data export, keyed by an [ExportKey].
///
/// Pure Dart (`package:cryptography`), no `dart:io`, so it runs on Android and
/// in the web build alike. Nothing here falls back to plaintext: any failure
/// is an [ExportCryptoException].
library;
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:diet_guard_app/services/export_keyring.dart';
/// The `enc` marker of the only envelope layout this build reads and writes.
const encryptionVersion = 'v2';
const _nonceBytes = 12;
const _macBytes = 16;
/// Binds a ciphertext to its role and key id, so it cannot be replayed.
List<int> _aad(String kid) =>
utf8.encode('diet_guard_export:kuchnia:$encryptionVersion:$kid');
/// Wrong key, tampered or malformed envelope.
class ExportCryptoException implements Exception {
/// Creates the exception.
const ExportCryptoException(this.message);
/// What was wrong; never contains key material.
final String message;
@override
String toString() => 'ExportCryptoException: $message';
}
/// Seals [plaintext] under [key] with a fresh random nonce.
///
/// `ciphertext` is the encrypted bytes followed by the 16-byte GCM tag.
Future<Map<String, Object?>> encryptWithKey(
String plaintext,
ExportKey key,
) async {
final rng = Random.secure();
final nonce = Uint8List.fromList([
for (var i = 0; i < _nonceBytes; i++) rng.nextInt(256),
]);
final box = await AesGcm.with256bits().encrypt(
utf8.encode(plaintext),
secretKey: SecretKey(key.bytes),
nonce: nonce,
aad: _aad(key.kid),
);
return {
'enc': encryptionVersion,
'alg': 'AES-256-GCM',
'kid': key.kid,
'nonce': base64Encode(nonce),
'ciphertext': base64Encode([...box.cipherText, ...box.mac.bytes]),
};
}
/// The key id an envelope was sealed with, or null when it has none.
String? envelopeKeyId(Object? envelope) {
if (envelope is! Map || envelope['enc'] != encryptionVersion) return null;
final kid = envelope['kid'];
return kid is String ? kid : null;
}
/// Opens an [encryptWithKey] envelope; throws [ExportCryptoException].
Future<String> decryptWithKey(Object? envelope, ExportKey key) async {
final Uint8List nonce;
final Uint8List sealed;
try {
final map = envelope! as Map;
if (envelopeKeyId(map) != key.kid) throw const FormatException('kid');
nonce = base64Decode(map['nonce']! as String);
sealed = base64Decode(map['ciphertext']! as String);
} on Object {
throw const ExportCryptoException('malformed envelope');
}
if (nonce.length != _nonceBytes || sealed.length < _macBytes) {
throw const ExportCryptoException('malformed envelope');
}
final cut = sealed.length - _macBytes;
try {
final clear = await AesGcm.with256bits().decrypt(
SecretBox(
sealed.sublist(0, cut),
nonce: nonce,
mac: Mac(sealed.sublist(cut)),
),
secretKey: SecretKey(key.bytes),
aad: _aad(key.kid),
);
return utf8.decode(clear);
} on Object {
throw const ExportCryptoException('wrong key or tampered data');
}
}

View File

@ -0,0 +1,126 @@
/// The export keyring: random 256-bit AES keys that unlock data exports.
///
/// Keys live in the OS keystore and also sync between devices as the
/// `export_keys` document (see `export_keyring_sync.dart`), so an export made
/// on one device opens on another. It is a keyring, not a single key: a key
/// is never deleted or overwritten, each id is its own record, and two
/// devices that mint a key at the same moment both keep both.
///
/// NOTE: the synced copy sits in Firebase RTDB in plaintext, so Firebase can
/// read these keys (and therefore the sealed login in any export it could get
/// hold of) until crdt E2E lands. `export_keys` must move under that
/// encryption too: `~/src/utils/TODO-crdt-e2e-encryption.md`.
library;
import 'dart:convert';
import 'dart:math';
import 'package:crdt_sync/crdt_sync.dart';
/// Keystore entry holding the whole ring as JSON.
const keyringStorageKey = 'diet_guard.export_keyring';
final _kidShape = RegExp(r'^[0-9a-f]{16}$');
const _keyBytes = 32;
/// One export key.
class ExportKey {
/// Creates a key; [kid] is 16 hex chars, [bytes] 32 bytes.
const ExportKey(this.kid, this.bytes, this.createdAt);
/// Random id written into every export sealed with this key.
final String kid;
/// The AES-256 key.
final List<int> bytes;
/// When it was minted (UTC); the newest key signs new exports.
final DateTime createdAt;
/// True when [kid] and [bytes] have the shape a real key has.
bool get isWellFormed => _kidShape.hasMatch(kid) && bytes.length == _keyBytes;
}
/// A keyring over an injected keystore.
///
/// Reads and writes throw when the keystore is unavailable or holds garbage;
/// callers treat that as "no keyring" and never fall back to plaintext. A
/// garbled ring is not replaced, since that would destroy the keys in it.
class ExportKeyring {
/// Creates a ring over [read]/[write] (the OS keystore in the app).
ExportKeyring({required this.read, required this.write});
/// Reads a keystore entry, or null when absent.
final SecretReader read;
/// Writes a keystore entry.
final SecretWriter write;
/// Every key held, in no particular order.
Future<List<ExportKey>> all() async {
final raw = await read(keyringStorageKey);
if (raw == null || raw.isEmpty) return [];
final decoded = jsonDecode(raw);
if (decoded is! Map) throw StateError('export keyring is corrupt');
return [
for (final MapEntry(key: kid, value: entry) in decoded.entries)
ExportKey(
kid as String,
base64Decode((entry as Map)['k'] as String),
DateTime.parse(entry['t'] as String).toUtc(),
),
];
}
/// The key with id [kid], or null when this device does not hold it.
Future<ExportKey?> byId(String kid) async {
for (final key in await all()) {
if (key.kid == kid) return key;
}
return null;
}
/// The newest key, minting and storing one if the ring is empty.
Future<ExportKey> ensureNewest() async {
var keys = await all();
if (keys.isEmpty) {
final rng = Random.secure();
final fresh = ExportKey(
[
for (var i = 0; i < 8; i++) rng.nextInt(256),
].map((b) => b.toRadixString(16).padLeft(2, '0')).join(),
[for (var i = 0; i < _keyBytes; i++) rng.nextInt(256)],
DateTime.now().toUtc(),
);
await mergeKeys([fresh]);
keys = [fresh];
}
return keys.reduce(
(a, b) =>
a.createdAt.isAfter(b.createdAt) ||
(a.createdAt == b.createdAt && a.kid.compareTo(b.kid) > 0)
? a
: b,
);
}
/// Adds the well-formed keys whose id is not held yet; never overwrites.
Future<void> mergeKeys(Iterable<ExportKey> incoming) async {
final held = {for (final key in await all()) key.kid: key};
final before = held.length;
for (final key in incoming) {
if (key.isWellFormed) held.putIfAbsent(key.kid, () => key);
}
if (held.length == before) return;
await write(
keyringStorageKey,
jsonEncode({
for (final key in held.values)
key.kid: {
'k': base64Encode(key.bytes),
't': key.createdAt.toIso8601String(),
},
}),
);
}
}

View File

@ -0,0 +1,50 @@
/// The app's real export keyring and its on-demand key pull.
library;
import 'dart:developer';
import 'package:diet_guard_app/services/export_keyring.dart';
import 'package:diet_guard_app/services/export_keyring_sync.dart';
import 'package:diet_guard_app/services/firebase_client.dart';
import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// Platform-channel code (keystore, network); the logic it wires lives in
// export_keyring*.dart and is covered there.
// coverage:ignore-start
const _secure = FlutterSecureStorage();
/// The keyring backed by the OS keystore.
ExportKeyring defaultKeyring() => ExportKeyring(
read: (key) => _secure.read(key: key),
write: (key, value) => _secure.write(key: key, value: value),
);
/// One sync pull of the keyring, for an import that met an unknown key id.
///
/// Best effort: offline or signed out just leaves the key unknown, and the
/// import reports that.
Future<void> pullExportKeys() async {
try {
final client = createGitHubClient(await SyncSettings.load());
try {
await syncExportKeys(
await syncBackend(client),
keyring: defaultKeyring(),
devicesDir: syncDevicesDir,
);
} finally {
client.close();
}
} on Object catch (error, stackTrace) {
log(
'export key pull failed',
level: 900,
error: error,
stackTrace: stackTrace,
);
}
}
// coverage:ignore-end

View File

@ -0,0 +1,85 @@
/// The `export_keys` document: the keyring as a crdt_sync log.
///
/// One record per key id, never deleted, so the merge is a plain union and
/// concurrent mints on two devices both survive. See `export_keyring.dart`
/// for the plaintext-at-Firebase caveat.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/services/export_keyring.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
const _keyField = 'k';
const _createdField = 't';
Log _toLog(Iterable<ExportKey> keys, String deviceId) => {
for (final key in keys)
key.kid: Record(
id: key.kid,
fields: {
_keyField: (
base64Encode(key.bytes),
Hlc.newTick(
deviceId,
wallTimeMsOverride: key.createdAt.millisecondsSinceEpoch,
),
),
_createdField: (
key.createdAt.toIso8601String(),
Hlc.newTick(
deviceId,
wallTimeMsOverride: key.createdAt.millisecondsSinceEpoch,
),
),
},
),
};
List<ExportKey> _fromLog(Log log) {
final keys = <ExportKey>[];
for (final record in log.values) {
try {
keys.add(
ExportKey(
record.id,
base64Decode(record.fields[_keyField]!.$1! as String),
DateTime.parse(record.fields[_createdField]!.$1! as String).toUtc(),
),
);
} on Object {
continue; // a malformed peer record is skipped, never fatal
}
}
return keys;
}
/// Pulls peers' keys into [keyring], pushes this device's keys.
///
/// Runs even when the ring is empty, so a fresh device still receives keys.
Future<void> syncExportKeys(
RemoteStore client, {
required ExportKeyring keyring,
required String devicesDir,
String? deviceId,
}) async {
final id = deviceId ?? currentSyncDeviceId;
final merged = await syncLog(
client: client,
deviceId: id,
legacyDeviceId: deviceId == null ? legacySyncDeviceId : null,
pathPrefix: devicesDir,
localLog: _toLog(await keyring.all(), id),
encode: (log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),
}),
decode: (text) => (jsonDecode(text) as Map).cast<String, dynamic>().map(
(kid, data) =>
MapEntry(kid, Record.fromJson(data as Map<String, dynamic>)),
),
filename: 'export_keys.json',
commitMessage: 'diet_guard_app sync',
);
await keyring.mergeKeys(_fromLog(merged));
}

View File

@ -0,0 +1,49 @@
/// Budget record local side, shared by the sync tick and the data import.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
/// This device's budget record; schedule-only edits stamp it at the epoch.
Log localBudgetLog() {
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
final scheduleEntries = MealScheduleService.history.entries;
final recordStamp =
updatedAt ??
(scheduleEntries.isEmpty ? null : MealScheduleService.updatedAt);
final localRecord = recordStamp == null
? null
: <String, dynamic>{
'v': 2,
'b': AppSettingsService.dailyKcalGoal,
't': (updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
.toIso8601String(),
};
return budgetToLog(
localRecord,
BudgetHistoryService.schedule.entries,
scheduleEntries,
);
}
/// Applies a merged budget [Log]: history, schedule, then the scalar.
Future<void> applyBudgetLog(Log merged) async {
await BudgetHistoryService.instance.applyMerged(logToHistory(merged));
if (MealScheduleService.isInitialized) {
await MealScheduleService.instance.applyMerged(
logToScheduleHistory(merged),
updatedAt: MealScheduleService.updatedAt,
);
}
final budget = logToBudget(merged);
final mergedKcal = budget?['b'];
if (budget == null || mergedKcal is! int) return;
await AppSettingsService.instance.applySyncedBudget(
mergedKcal,
updatedAt: DateTime.tryParse(budget['t'] as String? ?? ''),
);
}

View File

@ -5,6 +5,9 @@ import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
/// Where every device's synced files live on the remote.
const syncDevicesDir = 'diet-guard-sync/devices';
/// SharedPreferences key holding this install's device id.
///
/// The same key the sibling apps (`todo`, `home_inventory`, `wake_alarm`)

View File

@ -12,22 +12,23 @@
/// since a budget (unlike a food-log entry) can be edited repeatedly.
library;
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/export_keyring_default.dart';
import 'package:diet_guard_app/services/export_keyring_sync.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/services/sync_body.dart';
import 'package:diet_guard_app/services/sync_budget_local.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:diet_guard_app/services/sync_merge_kuchnia.dart';
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
import 'package:diet_guard_app/services/sync_state_factory.dart';
const _devicesDir = 'diet-guard-sync/devices';
const String _devicesDir = syncDevicesDir;
/// Runs one full sync tick: pull, merge, persist, push.
///
@ -63,6 +64,7 @@ Future<DayLog> runSync(RemoteStore client, {SyncStateStore? stateStore}) async {
await _syncFoodBank(client);
await _syncManualBank(client);
await _syncKuchniaCredential(client);
await _syncExportKeys(client);
await syncBody(client, devicesDir: _devicesDir);
return merged;
}
@ -110,72 +112,37 @@ Future<void> _syncManualBank(RemoteStore client) async {
}
/// Pulls other devices' budgets, merges, applies the winner locally, pushes.
///
/// Reuses [syncLog] (it always pushes the merged result, even an empty
/// one -- harmless and consistent with the food-log sync above). This
/// device contributes nothing to the merge until the goal has been
/// explicitly set at least once (see
/// [AppSettingsService.dailyKcalGoalUpdatedAt]), so a fresh install's
/// unset 2200 default can never spuriously outrank a real budget synced
/// from elsewhere.
Future<void> _syncBudget(RemoteStore client) async {
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
final scheduleEntries = MealScheduleService.history.entries;
// `budgetToLog` returns an empty Log for a null record, which would also
// drop the `sched:` fields. A device that has edited its schedule but never
// its budget still has something to contribute, so synthesise a record from
// the schedule's own edit time in that case. Its `value` carries the
// unset-default budget, but that loses every LWW race against a device that
// has actually set one, because this stamp is older than any real edit.
final scheduleStamp = MealScheduleService.updatedAt;
final recordStamp =
updatedAt ?? (scheduleEntries.isEmpty ? null : scheduleStamp);
final localRecord = recordStamp == null
? null
: <String, dynamic>{
'v': 2,
'b': AppSettingsService.dailyKcalGoal,
't': (updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
.toIso8601String(),
};
final mergedBudgetLog = await syncLog(
client: client,
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: _devicesDir,
localLog: budgetToLog(
localRecord,
BudgetHistoryService.schedule.entries,
scheduleEntries,
),
localLog: localBudgetLog(),
encode: encodeBudgetForPush,
decode: parseRemoteBudget,
filename: 'budget.json',
commitMessage: 'diet_guard_app sync',
);
await applyBudgetLog(mergedBudgetLog);
}
// The history merges by field union, so it is applied unconditionally --
// independently of whether the scalar budget resolved to anything.
await BudgetHistoryService.instance.applyMerged(
logToHistory(mergedBudgetLog),
);
// Same reasoning for the meal schedule: field-union merge, so applied
// regardless of how the scalar budget resolved.
if (MealScheduleService.isInitialized) {
await MealScheduleService.instance.applyMerged(
logToScheduleHistory(mergedBudgetLog),
updatedAt: scheduleStamp,
/// Syncs the export keyring; best effort, since sync must not fail on it.
Future<void> _syncExportKeys(RemoteStore client) async {
try {
await syncExportKeys(
client,
keyring: defaultKeyring(),
devicesDir: _devicesDir,
);
} on Object catch (error, stackTrace) {
log(
'export key sync failed',
level: 900,
error: error,
stackTrace: stackTrace,
);
}
final merged = logToBudget(mergedBudgetLog);
final mergedKcal = merged?['b'];
if (merged == null || mergedKcal is! int) return;
await AppSettingsService.instance.applySyncedBudget(
mergedKcal,
updatedAt: DateTime.tryParse(merged['t'] as String? ?? ''),
);
}
/// Pulls peers' catering credentials, merges, applies the winner locally.

View File

@ -0,0 +1,107 @@
/// Icon-only export and import of every synced document.
library;
import 'dart:async';
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
import 'package:diet_guard_app/services/data_export.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Suggested file name, identical on every platform.
const exportFileName = 'diet-guard-export.json';
enum _Outcome { idle, ok, failed }
/// Export (Save-as picker / download) and merge-only import buttons.
///
/// Feedback is the button's own icon (check or error) plus haptics.
class DataBackupRow extends StatefulWidget {
/// Creates a [DataBackupRow]; [onImported] runs after a successful import.
const DataBackupRow({super.key, this.onImported});
/// Called after an import merged, so the host can refresh shown values.
final VoidCallback? onImported;
@override
State<DataBackupRow> createState() => _DataBackupRowState();
}
class _DataBackupRowState extends State<DataBackupRow> {
_Outcome _export = _Outcome.idle;
_Outcome _import = _Outcome.idle;
Timer? _reset;
@override
void dispose() {
_reset?.cancel();
super.dispose();
}
Future<_Outcome> _run(Future<bool> Function() action) async {
try {
if (!await action()) return _Outcome.idle;
unawaited(HapticFeedback.lightImpact());
return _Outcome.ok;
} on Object {
unawaited(HapticFeedback.heavyImpact());
return _Outcome.failed;
}
}
void _show(void Function() update) {
if (!mounted) return;
setState(update);
_reset?.cancel();
_reset = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _export = _import = _Outcome.idle);
});
}
Future<void> _onExport() async {
final outcome = await _run(() async {
final text = await exportAllData();
return await exportJsonText(fileName: exportFileName, text: text);
});
_show(() => _export = outcome);
}
Future<void> _onImport() async {
final outcome = await _run(() async {
final text = await pickJsonText();
if (text == null) return false;
await importAllData(text);
return true;
});
if (outcome == _Outcome.ok) widget.onImported?.call();
_show(() => _import = outcome);
}
Widget _button(
_Outcome outcome,
IconData idle,
String label,
VoidCallback onPressed,
) {
final scheme = Theme.of(context).colorScheme;
final (icon, color, semantic) = switch (outcome) {
_Outcome.idle => (idle, null, label),
_Outcome.ok => (Icons.check_circle, scheme.primary, '$label: done'),
_Outcome.failed => (Icons.error, scheme.error, '$label: failed'),
};
return IconButton(
onPressed: onPressed,
icon: Icon(icon, color: color, semanticLabel: semantic),
);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
_button(_export, Icons.save_alt, 'Export data', _onExport),
_button(_import, Icons.file_open, 'Import data', _onImport),
],
);
}
}

View File

@ -122,8 +122,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.8"
cryptography:
dependency: "direct main"
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
data_transfer_flutter:
dependency: transitive
dependency: "direct main"
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.0"

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.124+1
version: 1.0.125+1
environment:
sdk: ^3.12.2
@ -14,6 +14,14 @@ dependencies:
ref: crdt_sync_dart-v0.11.3
path: crdt_sync_dart
crypto: 3.0.8
cryptography: 2.9.0
# Export/import file plumbing (Save-as picker, open picker), shared.
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.0
path: data_transfer_flutter
fl_chart: 1.3.0
flutter:
sdk: flutter

View File

@ -90,9 +90,9 @@ void main() {
expect(ok, isTrue);
// syncLog always pushes, even an empty merged result: food_log.json,
// budget.json, food_bank.json, food_bank_manual.json, kuchnia.json,
// body.json. Six data files plus this device's revision, which is what
// lets a later tick skip an unchanged peer.
expect(puts, 7);
// export_keys.json, body.json. Seven data files plus this device's
// revision, which is what lets a later tick skip an unchanged peer.
expect(puts, 8);
});
test('pushes under the persisted uuid, not the legacy role id', () async {

View File

@ -0,0 +1,194 @@
// The catering password rides in the export only as AEAD ciphertext under a
// random export key that the keyring syncs between devices.
import 'dart:convert';
import 'package:diet_guard_app/services/data_export.dart';
import 'package:diet_guard_app/services/data_export_kuchnia.dart';
import 'package:diet_guard_app/services/export_keyring_sync.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'body_test_support.dart';
import 'export_test_support.dart';
Future<void> _noPull() async {}
Future<Map<String, dynamic>> _root(MemorySecrets s) async =>
jsonDecode(await exportAllData(keyring: s.keyring()))
as Map<String, dynamic>;
Future<Object?> _importError(
String text,
MemorySecrets s, {
Future<void> Function() pull = _noPull,
}) async {
try {
await importAllData(text, keyring: s.keyring(), pullKeys: pull);
} on Object catch (error) {
return error;
}
return null;
}
String _tamper(String export) {
final root = jsonDecode(export) as Map<String, dynamic>;
final box = root['kuchnia'] as Map<String, dynamic>;
final bytes = base64Decode(box['ciphertext'] as String);
bytes[0] ^= 0x01;
box['ciphertext'] = base64Encode(bytes);
return jsonEncode(root);
}
void main() {
test('same device: login is restored into an empty local store', () async {
final secrets = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
final stamp = KuchniaCredentialService.editedAt;
final file = await exportAllData(keyring: secrets.keyring());
await bindFresh();
expect(KuchniaCredentialService.isConfigured, isFalse);
await importAllData(file, keyring: secrets.keyring(), pullKeys: _noPull);
expect(KuchniaCredentialService.username, testMail);
expect(KuchniaCredentialService.password, testPassword);
expect(KuchniaCredentialService.editedAt, stamp);
});
test('envelope: v2 AES-256-GCM, kid, fresh nonce, no KDF', () async {
final secrets = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
final a = (await _root(secrets))['kuchnia'] as Map<String, dynamic>;
final b = (await _root(secrets))['kuchnia'] as Map<String, dynamic>;
expect(a['enc'], 'v2');
expect(a['alg'], 'AES-256-GCM');
expect(a['kid'], b['kid']);
expect(a.containsKey('kdf'), isFalse);
expect(base64Decode(a['nonce'] as String), hasLength(12));
expect(a['nonce'], isNot(b['nonce']));
expect(a['ciphertext'], isNot(b['ciphertext']));
expect(secrets.data, hasLength(1));
});
test('export contains neither the plaintext login nor the key', () async {
final secrets = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
final file = await exportAllData(keyring: secrets.keyring());
final key = (await secrets.keyring().ensureNewest()).bytes;
expect(file, isNot(contains(testPassword)));
expect(file, isNot(contains(testMail)));
expect(file, isNot(contains(base64Encode(utf8.encode(testPassword)))));
expect(file, isNot(contains(base64Encode(key))));
expect(
file,
isNot(
contains(key.map((b) => b.toRadixString(16).padLeft(2, '0')).join()),
),
);
});
test('second device imports after its keyring sync', () async {
final remote = MemoryRemote();
final a = MemorySecrets();
final b = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
await seedMeal('oats');
final file = await exportAllData(keyring: a.keyring());
await syncExportKeys(
remote,
keyring: a.keyring(),
devicesDir: 'devs',
deviceId: 'dev-a',
);
await bindFresh();
var pulls = 0;
await importAllData(
file,
keyring: b.keyring(),
pullKeys: () async {
pulls++;
await syncExportKeys(
remote,
keyring: b.keyring(),
devicesDir: 'devs',
deviceId: 'dev-b',
);
},
);
expect(pulls, 1);
expect(KuchniaCredentialService.password, testPassword);
expect(await meals(), contains('oats'));
});
test('unknown kid: reported cleanly, rest still imports', () async {
final a = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
await seedMeal('soup');
final file = await exportAllData(keyring: a.keyring());
await bindFresh();
final error = await _importError(file, MemorySecrets());
expect(error, isA<KuchniaImportException>());
expect(KuchniaCredentialService.isConfigured, isFalse);
expect(await meals(), contains('soup'));
});
test('a failing key pull is the same clean failure', () async {
final a = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
final file = await exportAllData(keyring: a.keyring());
await bindFresh();
final error = await _importError(
file,
MemorySecrets(),
pull: () async => throw StateError('offline'),
);
expect(error, isA<KuchniaImportException>());
});
test('tampered ciphertext is rejected, rest still imports', () async {
final a = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
await seedMeal('rice');
final file = _tamper(await exportAllData(keyring: a.keyring()));
await bindFresh();
final error = await _importError(file, a);
expect(error, isA<KuchniaImportException>());
expect(KuchniaCredentialService.isConfigured, isFalse);
expect(await meals(), contains('rice'));
});
test('keystore unavailable: kuchnia omitted and the skip recorded', () async {
final secrets = MemorySecrets()..broken = true;
await bindFresh(kuchnia: (testMail, testPassword));
final file = await exportAllData(keyring: secrets.keyring());
final root = jsonDecode(file) as Map<String, dynamic>;
expect(root.containsKey('kuchnia'), isFalse);
expect(root['kuchnia_skipped'], 'keystore_unavailable');
expect(file, isNot(contains(testPassword)));
});
test('an existing local login is kept, never overwritten', () async {
final a = MemorySecrets();
await bindFresh(kuchnia: (testMail, testPassword));
final file = await exportAllData(keyring: a.keyring());
await bindFresh(kuchnia: ('other@example.pl', 'local-pw'));
final stamp = KuchniaCredentialService.editedAt;
await importAllData(file, keyring: a.keyring(), pullKeys: _noPull);
expect(KuchniaCredentialService.username, 'other@example.pl');
expect(KuchniaCredentialService.password, 'local-pw');
expect(KuchniaCredentialService.editedAt, stamp);
});
test('no configured login: no box, no flag, no key minted', () async {
final secrets = MemorySecrets();
await bindFresh();
final root = await _root(secrets);
expect(root.containsKey('kuchnia'), isFalse);
expect(root.containsKey('kuchnia_skipped'), isFalse);
expect(secrets.data, isEmpty);
});
}

View File

@ -0,0 +1,172 @@
// Export -> import must be lossless, idempotent and merge-only.
import 'dart:convert';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/data_export.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'body_test_support.dart';
/// Points every service at a fresh in-memory store.
Future<MemoryDocStore> _bindFreshStore() async {
final store = MemoryDocStore();
LogStorageService.resetForTesting(store: store);
FoodBankService.resetForTesting(store: store);
await AppSettingsService.initForTesting(store);
await BudgetHistoryService.initForTesting(store);
await MealScheduleService.initForTesting(store);
await BodyService.initForTesting(store);
return store;
}
FoodEntry _entry(
String? id,
String time,
String desc, {
bool deleted = false,
}) => FoodEntry(
id: id,
time: time,
desc: desc,
grams: 100,
kcal: 250,
proteinG: 10,
carbsG: 30,
fatG: 8,
source: 'manual',
slotMinute: 480,
deleted: deleted,
);
/// Seeds one of every document type, incl. a tombstone and a legacy entry.
Future<void> _seed({String suffix = ''}) async {
final log = {
'2026-10-01': [
_entry('id-a$suffix', '2026-10-01T08:05:00', 'oats$suffix'),
_entry('id-b$suffix', '2026-10-01T12:10:00', 'soup', deleted: true),
_entry(null, '2026-10-01T16:00:00', 'legacy apple$suffix'),
],
};
await LogStorageService.instance.writeLog(log);
await FoodBankService.instance.rebuildAndPersist(log);
await FoodBankService.instance.applyMergedManualBank({
'skyr$suffix': FoodBankRecord(
desc: 'skyr$suffix',
kcal: 60,
proteinG: 11,
carbsG: 4,
fatG: 0,
grams: 100,
count: 1,
editedAt: '2026-10-02T09:00:00',
),
});
await AppSettingsService.instance.saveDailyKcalGoal(1900);
await MealScheduleService.instance.recordChange(
const MealSchedule(firstMinute: 450, lastMinute: 1230, count: 5),
when: DateTime(2026, 10, 3, 10),
);
await BodyService.instance.setWeight(
'2026-10-04',
81.5,
now: DateTime(2026, 10, 4, 7),
);
}
Map<String, dynamic> _documents(String export) =>
(jsonDecode(export) as Map<String, dynamic>)['documents']
as Map<String, dynamic>;
void main() {
test(
'round trip: import into an empty store reproduces the export',
() async {
await _bindFreshStore();
await _seed();
final original = await exportAllData();
final docs = _documents(original);
for (final key in ['food_log', 'food_bank', 'food_bank_manual']) {
expect(docs[key], isNotEmpty, reason: key);
}
expect(docs['budget'], isNotEmpty);
expect(docs['body'], isNotEmpty);
await _bindFreshStore();
await importAllData(original);
expect(_documents(await exportAllData()), _documents(original));
},
);
test('importing the same file twice equals importing it once', () async {
await _bindFreshStore();
await _seed();
final file = await exportAllData();
final store = await _bindFreshStore();
await importAllData(file);
final once = Map.of(store.documents);
await importAllData(file);
expect(store.documents, once);
});
test('import into a non-empty store keeps the existing records', () async {
await _bindFreshStore();
await _seed(suffix: '-file');
final file = await exportAllData();
await _bindFreshStore();
await _seed(suffix: '-local');
final before = _documents(await exportAllData());
await importAllData(file);
final after = _documents(await exportAllData());
for (final key in ['food_log', 'food_bank', 'food_bank_manual']) {
final kept = (after[key] as Map).keys.toSet();
expect(kept, containsAll((before[key] as Map).keys), reason: key);
expect(kept, containsAll((_documents(file)[key] as Map).keys));
}
final descs = (await LogStorageService.instance.allEntriesNewestFirst())
.map((e) => e.desc);
expect(
descs,
containsAll(['oats-local', 'oats-file', 'legacy apple-file']),
);
});
test('import keeps food-bank counts the log cannot rebuild', () async {
await _bindFreshStore();
await _seed(suffix: '-file');
final file = await exportAllData();
await _bindFreshStore();
await FoodBankService.instance.writeBank({
'pierogi': const FoodBankRecord(
desc: 'pierogi',
kcal: 300,
proteinG: 9,
carbsG: 40,
fatG: 11,
grams: 200,
count: 7,
),
});
await importAllData(file);
final bank = await FoodBankService.instance.readBank();
expect(bank['pierogi']?.count, 7);
});
test('rejects a file that is not a diet_guard export', () async {
await _bindFreshStore();
expect(() => importAllData('{"days": []}'), throwsFormatException);
expect(() => importAllData('not json'), throwsFormatException);
});
}

View File

@ -0,0 +1,84 @@
// The export keyring: a never-overwritten set of random keys that syncs.
import 'package:diet_guard_app/services/export_keyring.dart';
import 'package:diet_guard_app/services/export_keyring_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'body_test_support.dart';
import 'export_test_support.dart';
void main() {
test(
'a key is generated once, 256-bit, and persisted in the keystore',
() async {
final secrets = MemorySecrets();
final first = await secrets.keyring().ensureNewest();
final again = await secrets.keyring().ensureNewest();
expect(first.bytes, hasLength(32));
expect(first.kid, matches(RegExp(r'^[0-9a-f]{16}$')));
expect(again.kid, first.kid);
expect(again.bytes, first.bytes);
expect(secrets.data, hasLength(1));
},
);
test(
'two devices generate concurrently: both keys survive the merge',
() async {
final remote = MemoryRemote();
final a = MemorySecrets();
final b = MemorySecrets();
final keyA = await a.keyring().ensureNewest();
final keyB = await b.keyring().ensureNewest();
expect(keyA.kid, isNot(keyB.kid));
for (final (id, secrets) in [('dev-a', a), ('dev-b', b), ('dev-a', a)]) {
await syncExportKeys(
remote,
keyring: secrets.keyring(),
devicesDir: 'devs',
deviceId: id,
);
}
for (final secrets in [a, b]) {
final keys = {for (final k in await secrets.keyring().all()) k.kid: k};
expect(keys.keys, containsAll([keyA.kid, keyB.kid]));
expect(keys[keyA.kid]!.bytes, keyA.bytes);
expect(keys[keyB.kid]!.bytes, keyB.bytes);
}
},
);
test(
'a synced record never replaces a key already held under its id',
() async {
final secrets = MemorySecrets();
final mine = await secrets.keyring().ensureNewest();
final other = MemorySecrets();
await other.keyring().mergeKeys([
ExportKey(mine.kid, List.filled(32, 7), DateTime.utc(2030)),
]);
await secrets.keyring().mergeKeys(await other.keyring().all());
expect((await secrets.keyring().byId(mine.kid))!.bytes, mine.bytes);
},
);
test('malformed synced keys are ignored', () async {
final secrets = MemorySecrets();
await secrets.keyring().mergeKeys([
ExportKey('not-a-kid', List.filled(32, 1), DateTime.utc(2030)),
ExportKey('0123456789abcdef', List.filled(5, 1), DateTime.utc(2030)),
]);
expect(await secrets.keyring().all(), isEmpty);
});
test('the newest local key is the one used for export', () async {
final secrets = MemorySecrets();
final ring = secrets.keyring();
await ring.mergeKeys([
ExportKey('aaaaaaaaaaaaaaaa', List.filled(32, 1), DateTime.utc(2026)),
ExportKey('bbbbbbbbbbbbbbbb', List.filled(32, 2), DateTime.utc(2027)),
]);
expect((await ring.ensureNewest()).kid, 'bbbbbbbbbbbbbbbb');
});
}

View File

@ -0,0 +1,80 @@
// Shared fixtures for the export-key tests.
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/export_keyring.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'body_test_support.dart';
/// The catering login the tests hide.
const testPassword = 'hunter2-PANEL-pw';
/// The catering account the tests hide.
const testMail = 'kuhy@example.pl';
/// An in-memory OS keystore; [broken] simulates it being unavailable.
class MemorySecrets {
/// Stored secrets by key.
final Map<String, String> data = {};
/// When true every access throws.
bool broken = false;
/// A keyring over this keystore.
ExportKeyring keyring() => ExportKeyring(
read: (key) async => broken ? throw StateError('no keystore') : data[key],
write: (key, value) async {
if (broken) throw StateError('no keystore');
data[key] = value;
},
);
}
/// Fresh in-memory stores; [kuchnia] seeds the catering login when given.
Future<void> bindFresh({(String, String)? kuchnia}) async {
final store = MemoryDocStore();
LogStorageService.resetForTesting(store: store);
FoodBankService.resetForTesting(store: store);
await AppSettingsService.initForTesting(store);
await BudgetHistoryService.initForTesting(store);
await MealScheduleService.initForTesting(store);
await BodyService.initForTesting(store);
await KuchniaCredentialService.initForTesting(MemoryDocStore());
if (kuchnia != null) {
await KuchniaCredentialService.instance.save(kuchnia.$1, kuchnia.$2);
}
}
/// Writes one meal into the food log.
Future<void> seedMeal(String desc) async {
final log = {
'2026-10-01': [
FoodEntry(
id: 'id-$desc',
time: '2026-10-01T08:05:00',
desc: desc,
grams: 100,
kcal: 250,
proteinG: 10,
carbsG: 30,
fatG: 8,
source: 'manual',
slotMinute: 480,
),
],
};
await LogStorageService.instance.writeLog(log);
await FoodBankService.instance.rebuildAndPersist(log);
}
/// Every meal description in the local food log.
Future<List<String>> meals() async =>
(await LogStorageService.instance.allEntriesNewestFirst())
.map((e) => e.desc)
.toList();