Compare commits

..

No commits in common. "main" and "v1.0.147" have entirely different histories.

96 changed files with 370 additions and 3703 deletions

View File

@ -108,9 +108,9 @@ repos:
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.11.0#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.2#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.5.3#subdirectory=sickdays
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
- mcp>=2,<3
- pydantic
- defusedxml==0.7.1

View File

@ -43,18 +43,6 @@ A sync tick pulls every other device's log from `kuhyx/syncs`
`(time, desc)` dedup), **re-signs every persisted entry**, rebuilds the food
bank, pushes back.
**End-to-end encrypted** (since 2026-11-12, `_sync_e2e.py` /
`app/lib/services/sync_e2e.dart`): everything lives on
`diet-guard-sync/devices-e2e` (revs `revs-e2e`, header `diet-guard-sync/e2e`),
fields sealed with a key from the shared passphrase
(`~/.config/crdt-sync/password`; keystore + lock icon in Settings on the
phone). Record ids, tombstones and clocks stay clear, so the food banks' ids
(food names) are visible. The old plaintext `diet-guard-sync/devices` (and
its `export_keys.json` keyring) is copied over and never written or deleted.
Locked = `SyncError` / `SyncLockedException`: nothing is pulled or pushed.
The PC keeps its derived key and copy memo in `~/.local/share/diet_guard/e2e`.
The wake-alarm weigh-ins come from `wake-alarm-sync/devices-e2e` (own header).
**On the PC, sync is event-driven — there is no periodic timer.** The old
`diet-guard-sync.timer` (~15 min) was deleted: it paid a network round-trip on
every idle tick to approximate the two moments state actually changes here.

View File

@ -1,7 +1,4 @@
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+UseSerialGC -XX:TieredStopAtLevel=1
org.gradle.caching=true
org.gradle.parallel=true
kotlin.compiler.execution.strategy=in-process
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false

View File

@ -1,35 +0,0 @@
/// A wordless page for an error the window itself loaded: Chrome would
/// otherwise paint its own "This page isn't working" sentences over an
/// empty error body. Only the title carries the app's name (OS-level label).
library;
import 'dart:io';
/// HTML page with only an icon — no words painted by the browser.
const String errorIconPage =
'<!doctype html><html><head><meta charset="utf-8"> '
'<title>Diet Guard</title> '
'<style>html,body{margin:0;height:100%; '
'background:#211D1B;display:flex;align-items:center; '
'justify-content:center}</style></head><body> '
'<svg width="96" height="96" viewBox="0 0 24 24" fill="none" '
'stroke="#A39892" stroke-width="2" stroke-linecap="round" '
'aria-hidden="true"><circle cx="12" cy="12" r="9"/> '
'<line x1="5.6" y1="18.4" x2="18.4" y2="5.6"/></svg></body></html>';
/// Whether [request] is the window loading a page, not the app's fetch().
bool isNavigation(HttpRequest request) =>
request.headers.value('sec-fetch-mode') == 'navigate';
/// Replaces an error answer to a page load with [errorIconPage]. Does
/// nothing for fetch() calls, successes, or a response already written.
void paintErrorIcon(HttpRequest request) {
final response = request.response;
if (!isNavigation(request) || response.statusCode < 400) return;
try {
response.headers.contentType = ContentType.html;
response.write(errorIconPage);
} on Exception {
return; // body already sent: leave it alone
}
}

View File

@ -19,7 +19,6 @@ library;
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/desktop/error_page.dart' show isNavigation;
import 'package:http/http.dart' as http;
/// GitHub's REST API base, which [GitHubProxy] fronts.
@ -178,13 +177,8 @@ class GitHubProxy {
}
void _passThrough(HttpRequest request, http.Response response) {
request.response.statusCode = response.statusCode;
if (isNavigation(request)) {
// Top-level navigation (window load) must not paint error JSON; the app's
// own fetch() calls never send 'navigate', so they still get the body.
return;
}
request.response
..statusCode = response.statusCode
..headers.contentType = ContentType.json
..add(response.bodyBytes);
}

View File

@ -24,7 +24,6 @@ import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/desktop/content_types.dart' as content_types;
import 'package:diet_guard_app/desktop/error_page.dart';
import 'package:diet_guard_app/desktop/github_proxy.dart';
import 'package:diet_guard_app/services/desktop_wrapper.dart';
import 'package:path/path.dart' as p;
@ -92,7 +91,6 @@ class WrapperServer {
} on Exception {
request.response.statusCode = HttpStatus.internalServerError;
}
paintErrorIcon(request);
await request.response.close();
}
}

View File

@ -16,7 +16,6 @@ import 'package:diet_guard_app/models/slot.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/ui/theme.dart';
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
import 'package:flutter/material.dart';
@ -52,7 +51,7 @@ mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
Future<void> _pickFutureDate() async {
final today = DateTime.now();
final tomorrow = DateTime(today.year, today.month, today.day + 1);
final picked = await pickDate(
final picked = await showDatePicker(
context: context,
firstDate: tomorrow,
lastDate: tomorrow.add(const Duration(days: 365)),

View File

@ -6,8 +6,6 @@ library;
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show E2ePassphraseButton;
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/screens/github_mirror_screen.dart';
import 'package:diet_guard_app/screens/settings_kuchnia.dart';
@ -17,7 +15,6 @@ import 'package:diet_guard_app/services/firebase_backend.dart';
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/services/sync_e2e.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
@ -194,15 +191,6 @@ class _SettingsScreenState extends State<SettingsScreen>
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openSyncSettings()),
),
// End-to-end encryption passphrase: closed lock while the last
// sync found no usable key, so nothing syncs until it is set.
ValueListenableBuilder<bool>(
valueListenable: syncLocked,
builder: (_, locked, _) => ListTile(
contentPadding: EdgeInsets.zero,
leading: E2ePassphraseButton(locked: locked),
),
),
// Legacy mirror: muted tint marks it as the secondary path.
ListTile(
contentPadding: EdgeInsets.zero,

View File

@ -6,10 +6,10 @@
/// 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.
///
/// The synced copy is end-to-end encrypted under the shared crdt-sync
/// passphrase (`sync_e2e.dart`), on `diet-guard-sync/devices-e2e`. The old
/// plaintext `diet-guard-sync/devices/<id>/export_keys.json` nodes are
/// copied over once and then neither read nor written by this app.
/// 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';

View File

@ -8,7 +8,6 @@ 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_e2e.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -32,7 +31,7 @@ Future<void> pullExportKeys() async {
final client = createGitHubClient(await SyncSettings.load());
try {
await syncExportKeys(
await openDietGuardStore(await syncBackend(client)),
await syncBackend(client),
keyring: defaultKeyring(),
devicesDir: syncDevicesDir,
);

View File

@ -1,9 +1,8 @@
/// 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. [syncExportKeys] must be
/// handed the end-to-end encrypted store (`sync_e2e.dart`): these are the
/// keys that open every export.
/// concurrent mints on two devices both survive. See `export_keyring.dart`
/// for the plaintext-at-Firebase caveat.
library;
import 'dart:convert';
@ -71,7 +70,6 @@ Future<void> syncExportKeys(
deviceId: id,
legacyDeviceId: deviceId == null ? legacySyncDeviceId : null,
pathPrefix: devicesDir,
revsPath: syncRevsDir,
localLog: _toLog(await keyring.all(), id),
encode: (log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),

View File

@ -24,20 +24,6 @@ class LocalNotificationsBackend implements NotificationBackend {
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
);
await plugin.initialize(settings: settings);
// Re-create the channel so Android renames it if an install still shows
// the old "Meal reminders" label.
await plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
}
/// Requests Android 13+'s runtime `POST_NOTIFICATIONS` permission.

View File

@ -14,24 +14,16 @@ import 'package:diet_guard_app/services/wake_alarm_weights.dart';
/// Runs the Body sync against [client], under [devicesDir].
///
/// [wakeAlarm] is wake-alarm's own encrypted store; null (locked, or not
/// opened) skips the weigh-in ingest and nothing else.
///
/// No-ops when [BodyService] is uninitialised (a widget test, or an isolate
/// that never loaded it), so it can neither publish nor clobber anything.
Future<void> syncBody(
RemoteStore client, {
required String devicesDir,
RemoteStore? wakeAlarm,
}) async {
Future<void> syncBody(RemoteStore client, {required String devicesDir}) async {
if (!BodyService.isInitialized) return;
if (wakeAlarm != null) await ingestWakeAlarmWeights(wakeAlarm);
await ingestWakeAlarmWeights(client);
final merged = await syncLog(
client: client,
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: devicesDir,
revsPath: syncRevsDir,
localLog: bodyToLog(BodyService.instance.document),
encode: encodeBodyForPush,
decode: parseRemoteBody,

View File

@ -5,14 +5,8 @@ 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: the end-to-end
/// encrypted twin of the old plaintext `diet-guard-sync/devices` (see
/// `sync_e2e.dart`; `E2eLayout.of` derives the same names).
const syncDevicesDir = 'diet-guard-sync/devices-e2e';
/// Where peers publish their (keyed) revisions, apart from the old
/// plaintext `diet-guard-sync/revs` the E2E copy fingerprints.
const syncRevsDir = 'diet-guard-sync/revs-e2e';
/// Where every device's synced files live on the remote.
const syncDevicesDir = 'diet-guard-sync/devices';
/// SharedPreferences key holding this install's device id.
///

View File

@ -1,86 +0,0 @@
/// End-to-end encryption of everything this app syncs.
///
/// Every blob under `diet-guard-sync/devices-e2e` (food log, banks, budget,
/// catering credential, Body, export keyring) is sealed with a key derived
/// from the shared crdt-sync passphrase kept in the OS keystore; Firebase and
/// the GitHub mirror only see record ids, tombstones and clocks. The
/// wake-alarm weigh-ins live on their own prefix with their own header, so
/// they get a second store over the same backend.
///
/// Fail closed: without a usable key nothing is pulled or pushed. The old
/// plaintext `diet-guard-sync/devices` is copied over on open (picking up
/// writes from builds not yet migrated) and never written or deleted here.
/// Mirrors `diet_guard/_sync_e2e.py`.
library;
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show openEncryptedStoreFromKeystore;
import 'package:flutter/foundation.dart';
/// The plaintext prefix every pre-E2E build wrote; only the copy reads it.
const legacyDevicesDir = 'diet-guard-sync/devices';
/// Where the wake-alarm app published before E2E.
const legacyWakeAlarmDir = 'wake-alarm-sync/devices';
/// Argon2id cost for a header this device creates; only tests lower it.
@visibleForTesting
Argon2Params e2eArgon2Params = Argon2Params.recommended;
/// Whether the last open found no usable key, for the lock button.
final ValueNotifier<bool> syncLocked = ValueNotifier<bool>(false);
/// Thrown instead of syncing when end-to-end encryption has no usable key.
class SyncLockedException implements Exception {
/// Creates the exception for [reason].
const SyncLockedException(this.reason);
/// Why the store is locked.
final E2eLockReason reason;
@override
String toString() => 'sync locked (${reason.name})';
}
Future<E2eState> _open(RemoteStore backend, String oldPrefix) =>
openEncryptedStoreFromKeystore(
store: backend,
oldPrefix: oldPrefix,
params: e2eArgon2Params,
);
/// The encrypted diet_guard store over [backend], the outermost store.
///
/// Throws [SyncLockedException] when locked, so no caller can fall through
/// to a plaintext sync.
Future<RemoteStore> openDietGuardStore(RemoteStore backend) async {
final state = await _open(backend, legacyDevicesDir);
syncLocked.value = state is! E2eReady;
if (state is E2eReady) return state.store;
throw SyncLockedException(
state is E2eLocked ? state.reason : E2eLockReason.badHeader,
);
}
/// The encrypted wake-alarm store over [backend], or null when locked or
/// unreachable.
///
/// Its weigh-ins are optional input to the diet sync, so nothing here may
/// fail the tick: a lock or a transport error skips only the ingest.
Future<RemoteStore?> openWakeAlarmStore(RemoteStore backend) async {
try {
final state = await _open(backend, legacyWakeAlarmDir);
return state is E2eReady ? state.store : null;
} on Object catch (error, stackTrace) {
log(
'wake-alarm store unavailable; skipping weigh-ins',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
}

View File

@ -24,7 +24,6 @@ import 'package:diet_guard_app/services/log_storage_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_e2e.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_state_factory.dart';
@ -33,19 +32,10 @@ const String _devicesDir = syncDevicesDir;
/// Runs one full sync tick: pull, merge, persist, push.
///
/// [backend] is the outermost raw store (the Firebase/GitHub mirror);
/// everything goes through its end-to-end encrypted view, and a locked
/// device throws [SyncLockedException] before a single read or write.
///
/// Returns the merged log as it now sits on disk locally. Propagates any
/// [GitHubSyncError] from the client for the caller (auto-sync / the manual
/// "Sync now" action) to decide how to report.
Future<DayLog> runSync(
RemoteStore backend, {
SyncStateStore? stateStore,
}) async {
final client = await openDietGuardStore(backend);
final wakeAlarm = await openWakeAlarmStore(backend);
Future<DayLog> runSync(RemoteStore client, {SyncStateStore? stateStore}) async {
final logService = LogStorageService.instance;
final local = await logService.readLog();
@ -54,7 +44,6 @@ Future<DayLog> runSync(
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: _devicesDir,
revsPath: syncRevsDir,
localLog: dayLogToLog(local),
encode: encodeLogForPush,
decode: parseRemoteLog,
@ -76,7 +65,7 @@ Future<DayLog> runSync(
await _syncManualBank(client);
await _syncKuchniaCredential(client);
await _syncExportKeys(client);
await syncBody(client, devicesDir: _devicesDir, wakeAlarm: wakeAlarm);
await syncBody(client, devicesDir: _devicesDir);
return merged;
}
@ -91,7 +80,6 @@ Future<void> _syncFoodBank(RemoteStore client) async {
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: _devicesDir,
revsPath: syncRevsDir,
localLog: foodBankToLog(await FoodBankService.instance.readBank()),
encode: encodeFoodBankForPush,
decode: parseRemoteFoodBank,
@ -114,7 +102,6 @@ Future<void> _syncManualBank(RemoteStore client) async {
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: _devicesDir,
revsPath: syncRevsDir,
localLog: manualBankToLog(local),
encode: encodeManualBankForPush,
decode: parseRemoteManualBank,
@ -131,7 +118,6 @@ Future<void> _syncBudget(RemoteStore client) async {
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: _devicesDir,
revsPath: syncRevsDir,
localLog: localBudgetLog(),
encode: encodeBudgetForPush,
decode: parseRemoteBudget,
@ -163,7 +149,8 @@ Future<void> _syncExportKeys(RemoteStore client) async {
///
/// The phone runs its own catering importer, so it cannot fetch anything until
/// the panel password reaches it -- that is the whole reason this credential
/// syncs at all. Sealed end-to-end like the rest of the synced state.
/// syncs at all. It is plaintext on the wire, like the rest of the synced
/// state; see `sync_merge_kuchnia.dart`.
///
/// A device that has no credential contributes an empty log, so it neither
/// blocks nor clobbers a peer's real value -- it relays it. Mirrors
@ -175,7 +162,6 @@ Future<void> _syncKuchniaCredential(RemoteStore client) async {
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: _devicesDir,
revsPath: syncRevsDir,
localLog: credentialToLog(
KuchniaCredentialService.username,
KuchniaCredentialService.password,

View File

@ -1,8 +1,7 @@
/// Morning weigh-ins from the wake-alarm app, as Body weight candidates.
///
/// The wake-alarm phone app publishes
/// `wake-alarm-sync/devices-e2e/<id>/alarm.json` (end-to-end encrypted, read
/// through wake-alarm's own `EncryptingStore`): a crdt_sync Log whose
/// `wake-alarm-sync/devices/<id>/alarm.json`: a crdt_sync Log whose
/// `alarm` record carries `fields[name] = [value, hlc]`. Two fields hold
/// weights:
///
@ -26,7 +25,7 @@ import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/services/sync_merge_body.dart';
/// Where the wake-alarm app publishes, one directory per device.
const wakeAlarmDevicesDir = 'wake-alarm-sync/devices-e2e';
const wakeAlarmDevicesDir = 'wake-alarm-sync/devices';
const _latestField = 'latest_weight_kg';
const _sessionsField = 'morning_sessions';

View File

@ -195,9 +195,6 @@ abstract final class AppWidth {
/// value reads as a layout error rather than an affordance.
static const double field = 420;
/// 440px — cap for a date/time picker dialog (7 day columns + year wheel).
static const double picker = 440;
/// Viewport height below which the layout should switch to its compact form.
///
/// 700px, i.e. 768 minus typical window chrome: the supported floor is a

View File

@ -7,7 +7,6 @@ import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/body/formula_figure.dart';
import 'package:diet_guard_app/widgets/body/workout_kind_icon.dart';
import 'package:flutter/material.dart';
/// How many of the newest sessions to list.
@ -186,22 +185,7 @@ class _SessionTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(session.day),
const SizedBox(width: AppSpacing.sm),
Icon(
kindIcon(session.kind),
size: 16,
color: muted,
semanticLabel: session.label,
),
if (session.source == 'manual') ...[
const SizedBox(width: AppSpacing.sm),
Flexible(child: Text(session.label)),
],
],
),
Text('${session.day} ${session.label}'),
Row(
children: [
Icon(

View File

@ -7,7 +7,6 @@ import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/body/weight_chart.dart';
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -81,7 +80,7 @@ class _DatedValueCardState extends State<DatedValueCard> {
String _fmt(double value) => '${oneDecimal(value)} ${widget.unit}';
Future<void> _pickDay() async {
final picked = await pickDate(
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime.now(),

View File

@ -6,7 +6,6 @@ import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -47,7 +46,7 @@ class _ProfileCardState extends State<ProfileCard> {
Future<void> _pickBirth() async {
final now = DateTime.now();
final initial = DateTime.tryParse(_birth ?? '') ?? DateTime(now.year - 30);
final picked = await pickDate(
final picked = await showDatePicker(
context: context,
firstDate: DateTime(1900),
lastDate: now,

View File

@ -1,13 +0,0 @@
/// Returns the activity-type icon for [kind].
library;
import 'package:flutter/material.dart';
/// Activity kind icons used by _SessionTile (in `activity_card.dart`).
IconData kindIcon(String kind) => switch (kind) {
'run' => Icons.directions_run,
'walk' => Icons.directions_walk,
'cycle' => Icons.directions_bike,
'strength' => Icons.fitness_center,
_ => Icons.sports,
};

View File

@ -9,7 +9,6 @@ import 'package:diet_guard_app/widgets/filters/filter_fields.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sort.dart';
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
import 'package:flutter/material.dart';
/// The draggable bottom sheet that edits the history filter and sort order.
@ -112,7 +111,7 @@ class FilterSheet extends StatelessWidget {
],
),
onPressed: () async {
final picked = await pickDateRange(
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),

View File

@ -6,7 +6,6 @@ import 'dart:async';
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
import 'package:flutter/material.dart';
/// Picks a minute of day, offering the 15-minute grid within
@ -57,8 +56,8 @@ class MealTimeDropdown extends StatelessWidget {
/// while the clock button may go to 23:44). Defaults to [maxMinute].
final int? gridMaxMinute;
/// Called with the chosen minute, always within [minMinute]..[maxMinute]
/// (the time picker cannot pick outside it). Not called when dismissed.
/// Called with the chosen minute, already clamped into
/// [minMinute]..[maxMinute]. Not called when the picker is dismissed.
final ValueChanged<int> onChanged;
/// The minutes listed, ascending: the grid marks in range plus [value].
@ -78,15 +77,28 @@ class MealTimeDropdown extends StatelessWidget {
}.toList()..sort();
}
int _clampToRange(int minute) => minute < minMinute
? minMinute
: (minute > maxMinute ? maxMinute : minute);
Future<void> _pickExact(BuildContext context) async {
final picked = await pickTime(
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60),
minMinute: minMinute,
maxMinute: maxMinute,
// Empty so the picker does not print its own heading over the clock.
helpText: '',
// The rest of the app writes times as HH:MM; an AM/PM dial here would
// be the one place that reads differently.
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
child: child!,
),
);
if (picked == null) return;
onChanged(picked.hour * 60 + picked.minute);
// Clamped rather than rejected: the picker cannot be told the range, and
// the nearest legal minute is what `MealSchedule.normalized` would
// produce anyway -- doing it here keeps the dropdown's own contract.
onChanged(_clampToRange(picked.hour * 60 + picked.minute));
}
@override

View File

@ -1,127 +0,0 @@
/// The date and date-range dialog: month chips above, the day grid with the
/// year wheel beside it, check and close icons below.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/pickers/date_grid.dart';
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
import 'package:diet_guard_app/widgets/pickers/month_chips.dart';
import 'package:diet_guard_app/widgets/pickers/picker_frame.dart';
import 'package:diet_guard_app/widgets/pickers/year_wheel.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Width of the year wheel beside the grid.
const double kYearWheelWidth = 64;
/// Pops a `DateTime` (single mode) or a `DateTimeRange` (range mode).
///
/// Keyboard: arrows move the grid cursor by a day or a week, Space taps the
/// cursor day in range mode, Enter confirms, Esc cancels.
class DateDialog extends StatefulWidget {
/// Creates a [DateDialog] over [selection].
const DateDialog({
required this.selection,
this.startOnYear = false,
this.today,
super.key,
});
/// The selection to edit; its mode decides the popped type.
final DateSelection selection;
/// Focus the year wheel first (the birth-date flow) instead of the grid.
final bool startOnYear;
/// The day to ring; now when null (a seam for tests).
final DateTime? today;
@override
State<DateDialog> createState() => _DateDialogState();
}
class _DateDialogState extends State<DateDialog> {
DateSelection get _sel => widget.selection;
bool _gridFocused = false;
void _confirm() => Navigator.of(
context,
).pop<Object>(_sel.range ? _sel.dateRange : _sel.start);
KeyEventResult _key(FocusNode _, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final step = switch (event.logicalKey) {
LogicalKeyboardKey.arrowLeft => -1,
LogicalKeyboardKey.arrowRight => 1,
LogicalKeyboardKey.arrowUp => -7,
LogicalKeyboardKey.arrowDown => 7,
_ => 0,
};
if (step != 0) {
setState(() => _sel.moveCursor(step));
return KeyEventResult.handled;
}
if (_sel.range && event.logicalKey == LogicalKeyboardKey.space) {
setState(() => _sel.tap(_sel.cursor));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) => PickerFrame(
canConfirm: _sel.canConfirm,
onConfirm: _confirm,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MonthChips(
year: _sel.year,
selected: _sel.month,
allowed: (m) => _sel.monthAllowed(_sel.year, m),
onTap: (m) => setState(() => _sel.showMonth(m)),
),
const SizedBox(height: AppSpacing.sm),
LayoutBuilder(
builder: (context, constraints) {
final gridWidth =
constraints.maxWidth - kYearWheelWidth - AppSpacing.sm;
return SizedBox(
height: gridWidth / 7 * (kGridCells ~/ 7),
child: Row(
spacing: AppSpacing.sm,
children: [
Expanded(
child: Focus(
autofocus: !widget.startOnYear,
onKeyEvent: _key,
onFocusChange: (f) => setState(() => _gridFocused = f),
child: DateGrid(
selection: _sel,
today: widget.today ?? DateTime.now(),
showCursor: _gridFocused,
onTap: (day) => setState(() => _sel.tap(day)),
),
),
),
SizedBox(
width: kYearWheelWidth,
child: YearWheel(
firstYear: _sel.first.year,
lastYear: _sel.last.year,
value: _sel.year,
autofocus: widget.startOnYear,
onChanged: (y) => setState(() => _sel.showYear(y)),
),
),
],
),
);
},
),
],
),
);
}

View File

@ -1,74 +0,0 @@
/// The day grid: one month of numerals, Monday first, with no weekday header.
///
/// One widget serves the single-date and the range picker; the mode lives in
/// [DateSelection], so the range picker is a mode of this grid, not a copy.
library;
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
import 'package:flutter/material.dart';
/// Six rows of seven: every month fits, and the dialog never changes height.
const int kGridCells = 42;
/// The shown month of [selection] as a numeral grid.
///
/// Today has a ring; weekends are dimmed with a dot; out-of-range days are
/// dimmed further and inert. The chosen day, or both range ends, are filled;
/// the days of a range are shaded.
class DateGrid extends StatelessWidget {
/// Creates a [DateGrid].
const DateGrid({
required this.selection,
required this.today,
required this.onTap,
this.showCursor = false,
super.key,
});
/// The selection being edited (read only here).
final DateSelection selection;
/// Today's date, ringed.
final DateTime today;
/// Called with a tapped in-range day.
final ValueChanged<DateTime> onTap;
/// Whether to outline the keyboard cursor (the grid has focus).
final bool showCursor;
bool _inBand(DateTime day) {
final start = selection.start;
final end = selection.end;
if (start == null || end == null || start == end) return false;
return !day.isBefore(start) && !day.isAfter(end);
}
@override
Widget build(BuildContext context) {
final l10n = MaterialLocalizations.of(context);
final year = selection.year;
final month = selection.month;
final lead = DateTime(year, month).weekday - DateTime.monday;
final days = DateUtils.getDaysInMonth(year, month);
return numeralRows(7, [
for (var i = 0; i < lead; i++) const SizedBox.shrink(),
for (var d = 1; d <= days; d++) _cell(DateTime(year, month, d), l10n),
for (var i = lead + days; i < kGridCells; i++) const SizedBox.shrink(),
]);
}
Widget _cell(DateTime day, MaterialLocalizations l10n) => NumeralCell(
key: ValueKey(day),
label: '${day.day}',
semanticLabel: l10n.formatFullDate(day),
onTap: () => onTap(day),
enabled: selection.allows(day),
selected: day == selection.start || day == selection.end,
band: _inBand(day),
ring: DateUtils.isSameDay(day, today),
dot: day.weekday >= DateTime.saturday,
focused: showCursor && day == selection.cursor,
);
}

View File

@ -1,140 +0,0 @@
/// The date picker's selection rules, kept free of widgets so each rule is a
/// plain unit test: bounds clamp, the two-tap range, the month/year day clamp.
library;
import 'dart:math' as math;
import 'package:flutter/material.dart' show DateTimeRange, DateUtils;
/// What the date picker has chosen, and which month it is showing.
///
/// Every date is date-only (midnight local); days are stepped with
/// `DateTime(y, m, d + n)`, never a `Duration`, so DST cannot shift a day.
class DateSelection {
/// A single-date selection starting on [initial], clamped into bounds.
DateSelection.single({
required DateTime first,
required DateTime last,
DateTime? initial,
}) : first = DateUtils.dateOnly(first),
last = DateUtils.dateOnly(last),
range = false {
start = _clamp(initial ?? DateTime.now());
_init(start!);
}
/// A two-tap range selection. A preset [initial] range counts as both taps
/// done, so it can be confirmed at once.
DateSelection.range({
required DateTime first,
required DateTime last,
DateTimeRange? initial,
}) : first = DateUtils.dateOnly(first),
last = DateUtils.dateOnly(last),
range = true {
if (initial != null) {
start = _clamp(initial.start);
end = _clamp(initial.end);
}
_init(start ?? _clamp(DateTime.now()));
}
/// Earliest selectable day.
final DateTime first;
/// Latest selectable day.
final DateTime last;
/// Whether this is the two-tap range mode.
final bool range;
/// The chosen day (single mode) or the range start.
DateTime? start;
/// The range end; null until the second tap.
DateTime? end;
/// The keyboard cursor; equals [start] in single mode.
late DateTime cursor;
/// Shown year.
late int year;
/// Shown month, 1-12.
late int month;
void _init(DateTime at) {
cursor = at;
year = at.year;
month = at.month;
}
DateTime _clamp(DateTime day) {
final d = DateUtils.dateOnly(day);
if (d.isBefore(first)) return first;
return d.isAfter(last) ? last : d;
}
/// Whether [day] lies within the bounds.
bool allows(DateTime day) => !day.isBefore(first) && !day.isAfter(last);
/// Whether any day of month [m] of year [y] lies within the bounds.
bool monthAllowed(int y, int m) =>
!DateTime(y, m + 1, 0).isBefore(first) && !DateTime(y, m).isAfter(last);
/// Whether the check may be pressed: always in single mode, after both
/// taps in range mode.
bool get canConfirm => !range || end != null;
/// The range chosen so far, once both ends are set.
DateTimeRange? get dateRange =>
end == null ? null : DateTimeRange(start: start!, end: end!);
/// A tap on [day]. Out-of-range days are inert. In range mode: the first
/// tap sets the start, the second the end (in either order), a third
/// restarts from [day].
void tap(DateTime day) {
if (!allows(day)) return;
cursor = day;
if (!range) {
start = day;
} else if (start == null || end != null) {
start = day;
end = null;
} else if (day.isBefore(start!)) {
end = start;
start = day;
} else {
end = day;
}
}
/// Moves the cursor by [days], following it into another month. Stops at
/// the bounds. In single mode the selection moves with it.
void moveCursor(int days) {
final day = DateTime(cursor.year, cursor.month, cursor.day + days);
if (!allows(day)) return;
_init(day);
if (!range) start = day;
}
/// Shows month [m] of the shown year.
void showMonth(int m) => _show(year, m);
/// Shows year [y], keeping the month where the bounds allow.
void showYear(int y) => _show(y, month);
/// Shows ([y], [m]), pulling the month into bounds and the cursor day to
/// the new month's last day where it is shorter. A range's chosen ends
/// never move.
void _show(int y, int m) {
var shown = m;
if (y == first.year && shown < first.month) shown = first.month;
if (y == last.year && shown > last.month) shown = last.month;
final day = math.min(cursor.day, DateUtils.getDaysInMonth(y, shown));
year = y;
month = shown;
cursor = _clamp(DateTime(y, shown, day));
if (!range) start = cursor;
}
}

View File

@ -1,30 +0,0 @@
/// The endless child delegate of the looping time wheels.
library;
import 'package:flutter/widgets.dart';
/// An endless delegate that passes the absolute index to its builder, so a
/// tapped item knows which of its many copies it is (the framework's
/// looping delegate only hands out the wrapped child).
class LoopWheelDelegate extends ListWheelChildDelegate {
/// Creates a delegate looping over [count] values.
LoopWheelDelegate(this.count, this.builder);
/// Number of distinct values before the wheel wraps.
final int count;
/// Builds the item at an absolute (unwrapped) index.
final Widget Function(int index) builder;
@override
Widget? build(BuildContext context, int index) => builder(index);
@override
int? get estimatedChildCount => null;
@override
int trueIndexOf(int index) => index % count;
@override
bool shouldRebuild(covariant ListWheelChildDelegate oldDelegate) => true;
}

View File

@ -1,46 +0,0 @@
/// The month row of the date picker: numerals 1-12 instead of month names.
library;
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
import 'package:flutter/material.dart';
/// Twelve numeral chips in two rows of six; months wholly outside the bounds
/// are dimmed and inert.
class MonthChips extends StatelessWidget {
/// Creates a [MonthChips].
const MonthChips({
required this.year,
required this.selected,
required this.allowed,
required this.onTap,
super.key,
});
/// The shown year (for the spoken month names).
final int year;
/// The shown month, 1-12.
final int selected;
/// Whether month 1-12 has any day within the bounds.
final bool Function(int month) allowed;
/// Called with a tapped allowed month.
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
final l10n = MaterialLocalizations.of(context);
return numeralRows(6, aspectRatio: 1.6, [
for (var m = 1; m <= 12; m++)
NumeralCell(
key: ValueKey('month-$m'),
label: '$m',
semanticLabel: l10n.formatMonthYear(DateTime(year, m)),
onTap: () => onTap(m),
enabled: allowed(m),
selected: m == selected,
),
]);
}
}

View File

@ -1,153 +0,0 @@
/// One tappable numeral of the pickers' grids (a day, a month), plus the
/// equal-width row layout both grids use.
library;
import 'package:flutter/material.dart';
/// Alpha of a weekend numeral: dimmed, with a dot as the second channel.
const double kWeekendAlpha = 0.6;
/// Alpha of an out-of-range numeral: far below [kWeekendAlpha], so an
/// out-of-range weekend never reads like an in-range one.
const double kDisabledAlpha = 0.25;
/// Alpha of the shaded band between two range ends.
const double kBandAlpha = 0.3;
/// A numeral in a stadium. Every visual state is a field, so tests assert
/// state rather than colours.
class NumeralCell extends StatelessWidget {
/// Creates a [NumeralCell].
const NumeralCell({
required this.label,
required this.semanticLabel,
required this.onTap,
this.enabled = true,
this.selected = false,
this.band = false,
this.ring = false,
this.dot = false,
this.focused = false,
super.key,
});
/// The painted numeral.
final String label;
/// The spoken name (a full date, a month); never painted.
final String semanticLabel;
/// Called on tap when [enabled].
final VoidCallback onTap;
/// Out-of-range cells are dimmed and inert.
final bool enabled;
/// Filled: the chosen value (a range end is a filled cap).
final bool selected;
/// Shaded background: inside a chosen range.
final bool band;
/// Outlined: today.
final bool ring;
/// Dimmed with a dot below: a weekend.
final bool dot;
/// The keyboard cursor.
final bool focused;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final alpha = !enabled ? kDisabledAlpha : (dot ? kWeekendAlpha : 1.0);
final fg = selected
? scheme.onPrimary
: scheme.onSurface.withValues(alpha: alpha);
final edge = ring || focused
? BorderSide(color: focused ? scheme.outline : scheme.primary, width: 2)
: BorderSide.none;
final shape = StadiumBorder(side: edge);
return Semantics(
label: semanticLabel,
button: true,
selected: selected,
enabled: enabled,
// excludeSemantics drops the InkWell's own tap action, so the node
// offers it here or a screen reader cannot pick a day.
onTap: enabled ? onTap : null,
excludeSemantics: true,
child: ColoredBox(
// Translucent, not primaryContainer: in this theme that equals
// primary, which hid the filled range ends inside the band.
color: band
? scheme.primary.withValues(alpha: kBandAlpha)
: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(2),
child: Material(
type: MaterialType.transparency,
child: InkWell(
customBorder: shape,
canRequestFocus: false,
onTap: enabled ? onTap : null,
child: Ink(
decoration: ShapeDecoration(
shape: shape,
color: selected ? scheme.primary : null,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: fg,
fontWeight: selected ? FontWeight.bold : null,
),
),
if (dot)
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: fg,
shape: BoxShape.circle,
),
),
],
),
),
),
),
),
),
),
);
}
}
/// Lays [cells] out in rows of [columns] equal-width cells, each
/// [aspectRatio] wide per unit of height; a short last row is padded.
Widget numeralRows(int columns, List<Widget> cells, {double aspectRatio = 1}) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var r = 0; r < cells.length; r += columns)
Row(
children: [
for (var c = r; c < r + columns; c++)
Expanded(
child: AspectRatio(
aspectRatio: aspectRatio,
child: c < cells.length
? cells[c]
: const SizedBox.shrink(),
),
),
],
),
],
);

View File

@ -1,238 +0,0 @@
/// A controlled numeral wheel: the year wheel and both time wheels.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/pickers/loop_wheel_delegate.dart';
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Height of one wheel item.
const double kWheelItemExtent = 40;
/// A wheel of the values `offset .. offset + count - 1`, optionally looping.
///
/// Controlled: [value] is the truth and the wheel animates to it when the
/// parent changes it. Mouse drag scrolls (web excludes the mouse from drag by
/// default), a tap on any item selects it, arrow keys step by one. Values
/// failing [enabled] are dimmed and inert: [onChanged] never reports them and
/// a wheel that settles on one springs back to [value].
class NumeralWheel extends StatefulWidget {
/// Creates a [NumeralWheel].
const NumeralWheel({
required this.count,
required this.value,
required this.onChanged,
required this.semanticLabel,
this.offset = 0,
this.loop = false,
this.digits = 1,
this.enabled,
this.autofocus = false,
super.key,
});
/// Number of values.
final int count;
/// The value at index 0.
final int offset;
/// The current value.
final int value;
/// Called with each newly centred allowed value.
final ValueChanged<int> onChanged;
/// The spoken name of the wheel ("Year"); never painted.
final String semanticLabel;
/// Whether the last value wraps to the first.
final bool loop;
/// Zero-padded width of each numeral (2 for `05`).
final int digits;
/// Which values may be picked; all when null.
final bool Function(int value)? enabled;
/// Whether the wheel takes keyboard focus when it appears.
final bool autofocus;
@override
State<NumeralWheel> createState() => _NumeralWheelState();
}
class _NumeralWheelState extends State<NumeralWheel> {
late final FixedExtentScrollController _controller =
FixedExtentScrollController(initialItem: widget.value - widget.offset);
late int _centred = widget.value - widget.offset;
bool _focused = false;
int _valueAt(int index) =>
widget.offset + (widget.loop ? index % widget.count : index);
bool _ok(int value) => widget.enabled?.call(value) ?? true;
/// The index nearest the centre that shows [value] (a loop has many).
int _indexOf(int value) {
final target = value - widget.offset;
if (!widget.loop) return target;
final n = widget.count;
final delta = (target - _centred) % n;
return delta > n ~/ 2 ? _centred + delta - n : _centred + delta;
}
void _goTo(int index) => _controller.animateToItem(
index,
duration: AppDuration.fast,
curve: AppCurve.standard,
);
bool get _scrolling =>
_controller.hasClients && _controller.position.isScrollingNotifier.value;
@override
void didUpdateWidget(NumeralWheel old) {
super.didUpdateWidget(old);
// Not mid-drag: the parent echoing an intermediate value must not yank
// the wheel out from under the pointer.
if (widget.value != _valueAt(_centred) && !_scrolling) {
_afterFrame(() => _goTo(_indexOf(widget.value)));
}
}
void _afterFrame(VoidCallback action) =>
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) action();
});
/// The allowed value nearest [value], by value and never across the wrap
/// (08:00 out of range means 08:15, not 08:59). A fast drag can skip every
/// allowed value on its way past an end, so the last reported value is no
/// anchor.
int _nearestAllowed(int value) {
final last = widget.offset + widget.count - 1;
for (var d = 1; d < widget.count; d++) {
for (final v in [value - d, value + d]) {
if (v >= widget.offset && v <= last && _ok(v)) return v;
}
}
return widget.value;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _centre(int index) {
setState(() => _centred = index);
final value = _valueAt(index);
if (_ok(value) && value != widget.value) widget.onChanged(value);
}
KeyEventResult _key(FocusNode _, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final step = switch (event.logicalKey) {
LogicalKeyboardKey.arrowUp => -1,
LogicalKeyboardKey.arrowDown => 1,
_ => 0,
};
if (step == 0) return KeyEventResult.ignored;
final next = _centred + step;
final inList = widget.loop || (next >= 0 && next < widget.count);
if (inList && _ok(_valueAt(next))) _goTo(next);
return KeyEventResult.handled;
}
Widget _item(BuildContext context, int index) {
final value = _valueAt(index);
final ok = _ok(value);
final scheme = Theme.of(context).colorScheme;
final centred = index == _centred;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: ok ? () => _goTo(index) : null,
child: Center(
child: Text(
'$value'.padLeft(widget.digits, '0'),
key: ValueKey('wheel-$value'),
style: TextStyle(
fontSize: centred ? AppTextSize.title : AppTextSize.subtitle,
fontWeight: centred ? FontWeight.bold : null,
color: scheme.onSurface.withValues(alpha: ok ? 1 : kDisabledAlpha),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final behavior = ScrollConfiguration.of(context).copyWith(
dragDevices: PointerDeviceKind.values.toSet(),
scrollbars: false,
);
return Semantics(
label: widget.semanticLabel,
container: true,
child: Focus(
autofocus: widget.autofocus,
onKeyEvent: _key,
onFocusChange: (f) => setState(() => _focused = f),
child: Stack(
alignment: Alignment.center,
children: [
Container(
height: kWheelItemExtent,
decoration: BoxDecoration(
color: scheme.primaryContainer,
border: _focused
? Border.all(color: scheme.outline, width: 2)
: null,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
NotificationListener<ScrollEndNotification>(
onNotification: (_) {
if (!_ok(_valueAt(_centred))) {
_afterFrame(
() => _goTo(_indexOf(_nearestAllowed(_valueAt(_centred)))),
);
} else if (_valueAt(_centred) != widget.value) {
_afterFrame(() => _goTo(_indexOf(widget.value)));
}
return false;
},
child: ScrollConfiguration(
behavior: behavior,
child: ListWheelScrollView.useDelegate(
controller: _controller,
itemExtent: kWheelItemExtent,
physics: const FixedExtentScrollPhysics(),
diameterRatio: 1.6,
onSelectedItemChanged: _centre,
childDelegate: widget.loop
? LoopWheelDelegate(
widget.count,
(i) => _item(context, i),
)
: ListWheelChildBuilderDelegate(
childCount: widget.count,
builder: _item,
),
),
),
),
],
),
),
);
}
}

View File

@ -1,75 +0,0 @@
/// The shared dialog around every picker: a centred, width-capped [Dialog]
/// with a close icon (cancel) and a check icon (confirm), no words.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Picker chrome. Outside tap and Esc cancel (the dialog route's own
/// dismiss); Enter confirms when [canConfirm].
///
/// The icons carry spoken-only `semanticLabel`s and no `tooltip:`, which
/// would paint the word on long-press.
class PickerFrame extends StatelessWidget {
/// Creates a [PickerFrame].
const PickerFrame({
required this.child,
required this.canConfirm,
required this.onConfirm,
super.key,
});
/// The picker body.
final Widget child;
/// Whether the check is enabled (a half-chosen range is not).
final bool canConfirm;
/// Pops the dialog with the chosen value.
final VoidCallback onConfirm;
void _enter() {
if (canConfirm) onConfirm();
}
@override
Widget build(BuildContext context) => Dialog(
insetPadding: const EdgeInsets.all(AppSpacing.md),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.picker),
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Around the body only: Enter on a focused close icon must
// reach that button (cancel), not bubble up into a confirm.
CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.enter): _enter,
const SingleActivator(LogicalKeyboardKey.numpadEnter): _enter,
},
child: child,
),
const SizedBox(height: AppSpacing.sm),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: AppSpacing.sm,
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
IconButton.filled(
onPressed: canConfirm ? onConfirm : null,
icon: const Icon(Icons.check, semanticLabel: 'Confirm'),
),
],
),
],
),
),
),
);
}

View File

@ -1,67 +0,0 @@
/// Icon-only replacements for `showDatePicker`, `showDateRangePicker` and
/// `showTimePicker`, which paint "Select date", "Cancel", "OK" and tooltips.
///
/// Same arguments as the framework calls, so a call site changes by one
/// function name. Dropped on purpose: the weekday header, month names and
/// both text-entry modes (see the textgate rework report).
library;
import 'package:diet_guard_app/widgets/pickers/date_dialog.dart';
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
import 'package:diet_guard_app/widgets/pickers/time_dialog.dart';
import 'package:flutter/material.dart';
/// Picks one day in [firstDate]..[lastDate]; null when cancelled.
///
/// [initialDatePickerMode] `year` focuses the year wheel first.
Future<DateTime?> pickDate({
required BuildContext context,
required DateTime firstDate,
required DateTime lastDate,
DateTime? initialDate,
DatePickerMode initialDatePickerMode = DatePickerMode.day,
}) => showDialog<DateTime>(
context: context,
builder: (_) => DateDialog(
selection: DateSelection.single(
first: firstDate,
last: lastDate,
initial: initialDate,
),
startOnYear: initialDatePickerMode == DatePickerMode.year,
),
);
/// Picks a range of days in [firstDate]..[lastDate] by two taps; null when
/// cancelled.
Future<DateTimeRange?> pickDateRange({
required BuildContext context,
required DateTime firstDate,
required DateTime lastDate,
DateTimeRange? initialDateRange,
}) => showDialog<DateTimeRange>(
context: context,
builder: (_) => DateDialog(
selection: DateSelection.range(
first: firstDate,
last: lastDate,
initial: initialDateRange,
),
),
);
/// Picks a 24 h time whose minute of day lies in [minMinute]..[maxMinute];
/// null when cancelled.
Future<TimeOfDay?> pickTime({
required BuildContext context,
required TimeOfDay initialTime,
int minMinute = 0,
int maxMinute = 24 * 60 - 1,
}) => showDialog<TimeOfDay>(
context: context,
builder: (_) => TimeDialog(
initialMinute: initialTime.hour * 60 + initialTime.minute,
minMinute: minMinute,
maxMinute: maxMinute,
),
);

View File

@ -1,52 +0,0 @@
/// The time dialog: hour and minute wheels between check and close icons.
library;
import 'package:diet_guard_app/widgets/pickers/picker_frame.dart';
import 'package:diet_guard_app/widgets/pickers/time_wheels.dart';
import 'package:flutter/material.dart';
/// Pops the chosen [TimeOfDay], always within [minMinute]..[maxMinute].
class TimeDialog extends StatefulWidget {
/// Creates a [TimeDialog]; [initialMinute] is clamped into range, since a
/// value written by another device may lie outside it.
const TimeDialog({
required this.initialMinute,
required this.minMinute,
required this.maxMinute,
super.key,
});
/// The starting minute of day.
final int initialMinute;
/// Earliest allowed minute of day.
final int minMinute;
/// Latest allowed minute of day.
final int maxMinute;
@override
State<TimeDialog> createState() => _TimeDialogState();
}
class _TimeDialogState extends State<TimeDialog> {
late int _minute = clampMinute(
widget.initialMinute,
widget.minMinute,
widget.maxMinute,
);
@override
Widget build(BuildContext context) => PickerFrame(
canConfirm: true,
onConfirm: () => Navigator.of(
context,
).pop(TimeOfDay(hour: _minute ~/ 60, minute: _minute % 60)),
child: TimeWheels(
minute: _minute,
minMinute: widget.minMinute,
maxMinute: widget.maxMinute,
onChanged: (m) => setState(() => _minute = m),
),
);
}

View File

@ -1,91 +0,0 @@
/// The time picker's two looping wheels: hour 00-23 and minute 00-59, 24 h.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/pickers/numeral_wheel.dart';
import 'package:flutter/material.dart';
/// Width of one time wheel.
const double kTimeWheelWidth = 72;
/// Whether hour [h] holds any minute of day in [min]..[max].
bool hourAllowed(int h, int min, int max) =>
h * 60 + 59 >= min && h * 60 <= max;
/// [minute] of day pulled into [min]..[max].
int clampMinute(int minute, int min, int max) => minute.clamp(min, max);
/// Hour and minute wheels for a minute of day within [minMinute]..[maxMinute].
///
/// Hours and minutes outside the range are dimmed and inert. Changing the
/// hour pulls the minute to the nearest allowed one in that hour, so the
/// value reported is always in range.
class TimeWheels extends StatelessWidget {
/// Creates a [TimeWheels].
const TimeWheels({
required this.minute,
required this.minMinute,
required this.maxMinute,
required this.onChanged,
super.key,
});
/// The current minute of day (in range).
final int minute;
/// Earliest allowed minute of day.
final int minMinute;
/// Latest allowed minute of day.
final int maxMinute;
/// Called with each new in-range minute of day.
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final hour = minute ~/ 60;
Widget wheel(Widget child) => SizedBox(
width: kTimeWheelWidth,
height: kWheelItemExtent * 5,
child: child,
);
return Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.xs,
children: [
wheel(
NumeralWheel(
key: const ValueKey('hour'),
count: 24,
value: hour,
loop: true,
digits: 2,
semanticLabel: 'Hour',
autofocus: true,
enabled: (h) => hourAllowed(h, minMinute, maxMinute),
onChanged: (h) => onChanged(
clampMinute(h * 60 + minute % 60, minMinute, maxMinute),
),
),
),
const Text(':', style: TextStyle(fontSize: AppTextSize.title)),
wheel(
NumeralWheel(
key: const ValueKey('minute'),
count: 60,
value: minute % 60,
loop: true,
digits: 2,
semanticLabel: 'Minute',
enabled: (m) {
final v = hour * 60 + m;
return v >= minMinute && v <= maxMinute;
},
onChanged: (m) => onChanged(hour * 60 + m),
),
),
],
);
}
}

View File

@ -1,50 +0,0 @@
/// The date picker's year wheel: numerals, no loop.
library;
import 'package:diet_guard_app/widgets/pickers/numeral_wheel.dart';
import 'package:flutter/material.dart';
/// Out-of-range years listed beyond each bound, dimmed and inert, so the
/// wheel's ends read as limits rather than as a list that just stops.
const int kYearWheelPad = 2;
/// A non-looping wheel of the years [firstYear]..[lastYear], plus
/// [kYearWheelPad] dimmed, inert years past each end (a wheel dragged onto
/// one springs back).
class YearWheel extends StatelessWidget {
/// Creates a [YearWheel].
const YearWheel({
required this.firstYear,
required this.lastYear,
required this.value,
required this.onChanged,
this.autofocus = false,
super.key,
});
/// Earliest listed year.
final int firstYear;
/// Latest listed year.
final int lastYear;
/// The shown year.
final int value;
/// Called with a newly centred year.
final ValueChanged<int> onChanged;
/// Whether the wheel takes keyboard focus on open (the birth-date flow).
final bool autofocus;
@override
Widget build(BuildContext context) => NumeralWheel(
count: lastYear - firstYear + 1 + 2 * kYearWheelPad,
offset: firstYear - kYearWheelPad,
enabled: (y) => y >= firstYear && y <= lastYear,
value: value,
onChanged: onChanged,
semanticLabel: 'Year',
autofocus: autofocus,
);
}

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.3"
archive:
dependency: transitive
description:
@ -157,20 +157,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.14.1"
resolved-ref: edd0f7a7882cb1d3904932ba94faa282d08165df
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.14.1"
crdt_sync_flutter:
dependency: "direct main"
description:
path: crdt_sync_flutter
ref: "crdt_sync_flutter-v0.8.1"
resolved-ref: "3225af8365481677d61a8cabec0c0ad4d0407567"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.8.1"
version: "0.12.0"
cross_file:
dependency: transitive
description:
@ -199,11 +190,11 @@ packages:
dependency: "direct main"
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.2"
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
ref: "data_transfer_flutter-v0.2.1"
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.2"
version: "0.2.1"
dbus:
dependency: transitive
description:
@ -272,10 +263,10 @@ packages:
dependency: transitive
description:
name: file_picker
sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
url: "https://pub.dev"
source: hosted
version: "13.3.3"
version: "13.3.2"
file_picker_darwin:
dependency: transitive
description:
@ -288,10 +279,10 @@ packages:
dependency: transitive
description:
name: file_picker_linux
sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
url: "https://pub.dev"
source: hosted
version: "2.0.4"
version: "2.0.2"
file_picker_platform_interface:
dependency: transitive
description:
@ -304,10 +295,10 @@ packages:
dependency: transitive
description:
name: file_picker_web
sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8"
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
url: "https://pub.dev"
source: hosted
version: "4.2.1"
version: "4.2.0"
file_selector:
dependency: transitive
description:
@ -384,10 +375,10 @@ packages:
dependency: "direct main"
description:
name: fl_chart
sha256: "9f11b0b87a6b1843f5ca666ade4a62c08da1a6b7f9efeb91bb2c6f3fa6e31a9a"
sha256: "5ed69b8be550c6276c8f0bc25c09704fb061018ed1b1681867ad18fcf477e7b7"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.0"
flutter:
dependency: "direct main"
description: flutter
@ -405,10 +396,10 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "58f9ea245d7194363c05f7a012bbc73291736c2d8b5f555a8e0ea3f9121868b4"
sha256: e99e9c6078d1359ba131b2c1eb8d68837170836a01bf2cea3cf2fa13e62ac2ac
url: "https://pub.dev"
source: hosted
version: "23.0.1"
version: "23.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
@ -437,10 +428,10 @@ packages:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "8e80ac29433c71e267493b397457e3a8430c5c1d9c76bb58a6971c016cdba3d7"
sha256: c2d6efe9ba585d5f560d8b329131f00fe745834848e6221b22d1f62e0f0b16e2
url: "https://pub.dev"
source: hosted
version: "4.0.1"
version: "4.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
@ -503,11 +494,11 @@ packages:
dependency: "direct main"
description:
path: github_device_auth
ref: "github_device_auth-v0.4.0"
resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
ref: "github_device_auth-v0.3.2"
resolved-ref: "4ccaba1bb3e495ef72d70c6a7e7d7ec98d755792"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.4.0"
version: "0.3.2"
google_account_token:
dependency: "direct main"
description:
@ -553,10 +544,10 @@ packages:
dependency: "direct main"
description:
name: idb_shim
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
url: "https://pub.dev"
source: hosted
version: "2.11.3"
version: "2.11.2"
image:
dependency: transitive
description:
@ -833,10 +824,10 @@ packages:
dependency: transitive
description:
name: sembast
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
url: "https://pub.dev"
source: hosted
version: "3.10.1"
version: "3.10.0"
share_plus:
dependency: transitive
description:
@ -950,11 +941,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.7.3"
resolved-ref: "02d9d689e921d80ec155af9903164c3d2d259dce"
ref: "sync_settings_ui-v0.6.1"
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
url: "https://github.com/kuhyx/utils"
source: git
version: "0.7.3"
version: "0.6.1"
synchronized:
dependency: transitive
description:
@ -1119,10 +1110,10 @@ packages:
dependency: transitive
description:
name: windows_file_picker
sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.0.1"
workmanager:
dependency: "direct main"
description:

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.157+1
version: 1.0.147+1
environment:
sdk: ^3.12.2
@ -12,14 +12,8 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.14.1
ref: crdt_sync_dart-v0.12.0
path: crdt_sync_dart
# Keystore-backed end-to-end encryption open + the passphrase lock button.
crdt_sync_flutter:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_flutter-v0.8.1
path: crdt_sync_flutter
crypto: 3.0.8
cryptography: 2.9.0
@ -27,12 +21,12 @@ dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.2
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
fl_chart: 1.3.1
fl_chart: 1.3.0
flutter:
sdk: flutter
flutter_local_notifications: 23.0.1
flutter_local_notifications: 23.0.0
flutter_secure_storage: 11.2.0
# One-tap sign-in for the sync account. App-side only: crdt_sync stays pure
@ -40,7 +34,7 @@ dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.4.0
ref: github_device_auth-v0.3.2
path: github_device_auth
# One-tap Google ID token via the framework AccountManager chooser (FOSS).
google_account_token:
@ -50,7 +44,7 @@ dependencies:
path: google_account_token
health: 13.3.2
http: 1.6.0
idb_shim: 2.11.3
idb_shim: 2.11.2
path: 1.9.1
path_provider: 2.1.6
permission_handler: 13.0.2
@ -63,7 +57,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.7.3
ref: sync_settings_ui-v0.6.1
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -13,16 +13,6 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:path/path.dart' as p;
/// Removes <title> and <style> tags, strips all remaining HTML tags, and
/// returns every [A-Za-z]{2,} match — i.e. all visible words the browser would
/// paint for a top-level navigation.
List<String> paintedWords(String html) {
var noTitle = html.replaceAll(RegExp(r'<title>.*?</title>', dotAll: true), '');
var noStyle = noTitle.replaceAll(RegExp(r'<style>.*?</style>', dotAll: true), '');
var noTags = noStyle.replaceAll(RegExp(r'<[^>]*>'), '');
return RegExp(r'[A-Za-z]{2,}').allMatches(noTags).map((m) => m.group(0)!).toList();
}
void main() {
late Directory tempDir;
late WrapperServer server;
@ -79,75 +69,5 @@ void main() {
expect(response.statusCode, status);
expect(response.body, isEmpty, reason: 'nothing painted in the window');
});
test('$method $path loaded as a page shows only an icon', () async {
final request = http.Request(method, url(path))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, status);
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
}
group('proxy error responses', () {
late Directory tempDir;
late WrapperServer server;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_proxy_err_');
final webRoot = p.join(tempDir.path, 'web');
final dataDir = p.join(tempDir.path, 'data');
await Directory(webRoot).create(recursive: true);
File(p.join(webRoot, 'index.html')).writeAsStringSync('<html></html>');
await Directory(dataDir).create(recursive: true);
File(p.join(dataDir, 'documents')).writeAsStringSync('');
final tokenPath = p.join(tempDir.path, 'sync_token');
await File(tokenPath).writeAsString('t');
server = WrapperServer(
webRoot: webRoot,
dataDir: dataDir,
gitHubProxy: GitHubProxy(
tokenPath: tokenPath,
fallbackTokenPath: p.join(tempDir.path, 'fallback_token'),
httpClient: MockClient((_) async =>
http.Response('{"message": "Bad credentials"}', 401)),
),
);
await server.start(0);
});
tearDown(() async {
await server.stop();
await tempDir.delete(recursive: true);
});
Uri url(String path) => Uri.parse('http://localhost:${server.port}$path');
test('a proxy error loaded as a page shows only an icon', () async {
final request = http.Request('GET', url('/github/api/repos/o/r/contents/x'))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, HttpStatus.unauthorized);
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
test('the app fetch still gets the proxy error body', () async {
final response = await http.Response.fromStream(
await http.Client().send(http.Request(
'GET',
url('/github/api/repos/o/r/contents/x'),
)),
);
expect(response.statusCode, HttpStatus.unauthorized);
expect(response.body, contains('Bad credentials'));
});
});
}

View File

@ -1,35 +0,0 @@
/// A GitHub stand-in that persists the end-to-end encryption header, which
/// the sync service writes and re-reads; every other request goes to [inner].
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Wraps [inner] so `…/e2e` PUTs are stored and served back on GET.
MockClient e2eMock(Future<http.Response> Function(http.Request) inner) {
String? header;
return MockClient((request) async {
if (request.url.path.endsWith('/e2e.json')) {
// Firebase REST: the value is a JSON-encoded string.
if (request.method == 'PUT') {
header = jsonDecode(request.body) as String;
return http.Response(request.body, 200);
}
return http.Response(jsonEncode(header), 200);
}
if (!request.url.path.endsWith('/e2e')) return inner(request);
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
header = utf8.decode(base64.decode(body['content'] as String));
return http.Response(jsonEncode({'content': {}, 'sha': 'h'}), 200);
}
final text = header;
if (text == null) return http.Response('', 404);
return http.Response(
jsonEncode({'content': base64.encode(utf8.encode(text)), 'sha': 'h'}),
200,
);
});
}

View File

@ -1,5 +1,3 @@
import 'package:crdt_sync/crdt_sync.dart' show Argon2Params, resetE2eCopyMemo;
import 'package:diet_guard_app/services/sync_e2e.dart' show e2eArgon2Params;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -14,19 +12,10 @@ void installFakeSecureStorage({
Map<String, String>? initial,
bool throwing = false,
}) {
const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
resetE2eCopyMemo();
e2eArgon2Params = const Argon2Params(
memoryKiB: 8,
iterations: 1,
parallelism: 1,
const channel = MethodChannel(
'plugins.it_nomads.com/flutter_secure_storage',
);
// Sync is end-to-end encrypted, so a device with no passphrase is locked;
// tests that sync start with one unless they pass their own `initial`.
final store = <String, String>{
'crdt_sync_e2e_passphrase': 'test-passphrase',
...?initial,
};
final store = <String, String>{...?initial};
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;

View File

@ -1,6 +1,5 @@
// Guard: the Body screen, every tile sheet and the activity-method chooser
// paint no words (only numbers, unit symbols, MET and workout labels).
// Workout labels are content only for manual workouts (source == 'manual').
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -13,9 +12,10 @@ import 'screens/body_screen_support.dart';
/// proper names of the BMI / ideal-weight / BMR formulas that label their
/// figures (DOUBT rows in keep.md: kept from the first pilot, outside the
/// activity sheet). Activity formulas (ACSM, Per-km) are NOT here: that sheet
/// and its chooser show glyphs only. Only manual workouts carry human content.
/// and its chooser show glyphs only.
const Set<String> _workouts = {
'walk to the shop',
'Evening run',
'StrongLifts A',
'Harris-Benedict (revised 1984)',
'Peterson (BMI 22)',
'Mifflin-St Jeor',
@ -41,8 +41,8 @@ void main() {
expect(wordsIn('70.0 kg'), isEmpty);
expect(wordsIn('265 MET'), isEmpty);
expect(wordsIn('Measured (ACSM)'), isNotEmpty);
expect(wordsIn('walk to the shop 4 km', content: _workouts), ['km']);
expect(wordsIn('walk to the shop', content: _workouts), isEmpty);
expect(wordsIn('Evening run 4 km', content: _workouts), ['km']);
expect(wordsIn('Evening run', content: _workouts), isEmpty);
});
testWidgets('canary: a painted word fails the guard', (tester) async {

View File

@ -1,8 +1,8 @@
// Guard: the log-meal, history, calendar and settings screens paint no
// words; only the logged food name (content), numbers and unit symbols.
//
// The date/time pickers are not opened here: pickers/no_words_pickers_test.dart
// opens each one through its call site.
// Date/time pickers are framework chrome and are not opened here: they are a
// follow-up task (custom icon pickers), listed in the rework report.
import 'dart:io';

View File

@ -1,200 +0,0 @@
// The single-date dialog: grid state, confirm vs cancel, bounds, month and
// year navigation, keyboard and mouse, spoken labels.
import 'package:diet_guard_app/widgets/pickers/date_dialog.dart';
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
import 'package:diet_guard_app/widgets/pickers/year_wheel.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import 'picker_test_support.dart';
/// Tuesday 2026-03-10; March 2026 starts on a Sunday.
final _today = DateTime(2026, 3, 10);
DateSelection _sel({DateTime? first, DateTime? last}) => DateSelection.single(
first: first ?? DateTime(2024),
last: last ?? DateTime(2027, 12, 31),
initial: _today,
);
Future<Popped> _open(
WidgetTester tester,
DateSelection sel, {
bool startOnYear = false,
}) => openDialog(
tester,
DateDialog(selection: sel, today: _today, startOnYear: startOnYear),
);
void main() {
testWidgets('Monday first, no header: the 1st of March 2026 sits 7th', (
tester,
) async {
await _open(tester, _sel());
final row = tester.getTopLeft(find.byKey(ValueKey(DateTime(2026, 3))));
final mon = tester.getTopLeft(find.byKey(ValueKey(DateTime(2026, 3, 2))));
expect(mon.dy, greaterThan(row.dy), reason: 'Monday 2nd starts row 2');
expect(row.dx, greaterThan(mon.dx + 5 * 30), reason: 'Sunday is last');
});
testWidgets('today is ringed, weekends dimmed with a dot', (tester) async {
await _open(tester, _sel());
expect(cell(tester, _today).ring, isTrue);
expect(cell(tester, DateTime(2026, 3, 11)).ring, isFalse);
expect(cell(tester, DateTime(2026, 3, 14)).dot, isTrue, reason: 'Sat');
expect(cell(tester, DateTime(2026, 3, 15)).dot, isTrue, reason: 'Sun');
expect(cell(tester, DateTime(2026, 3, 16)).dot, isFalse);
});
testWidgets('a tap highlights; only the check confirms', (tester) async {
final popped = await _open(tester, _sel());
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, 19))));
await tester.pumpAndSettle();
expect(popped.closed, isFalse);
expect(cell(tester, DateTime(2026, 3, 19)).selected, isTrue);
expect(cell(tester, _today).selected, isFalse);
await confirm(tester);
expect(popped.value, DateTime(2026, 3, 19));
});
for (final how in ['close icon', 'outside tap', 'Esc']) {
testWidgets('$how cancels', (tester) async {
final popped = await _open(tester, _sel());
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, 19))));
await tester.pump();
switch (how) {
case 'close icon':
await tester.tap(controlByLabel('Cancel'));
case 'outside tap':
await tester.tapAt(const Offset(4, 4));
default:
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
}
await tester.pumpAndSettle();
expect(popped.closed, isTrue);
expect(popped.value, isNull);
});
}
testWidgets('out-of-range days and months are dimmed and inert', (
tester,
) async {
final sel = _sel(first: DateTime(2026, 3, 5), last: DateTime(2026, 4, 20));
await _open(tester, sel);
final early = DateTime(2026, 3, 4);
expect(cell(tester, early).enabled, isFalse);
await tester.tap(find.byKey(ValueKey(early)));
await tester.pump();
expect(sel.start, _today);
expect(cell(tester, 'month-5').enabled, isFalse);
await tester.tap(find.byKey(const ValueKey('month-5')));
await tester.pump();
expect(sel.month, 3);
expect(cell(tester, 'month-4').enabled, isTrue);
});
testWidgets('a 31st moved to April lands on the 30th', (tester) async {
final sel = _sel();
await _open(tester, sel);
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, 31))));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('month-4')));
await tester.pumpAndSettle();
expect(cell(tester, DateTime(2026, 4, 30)).selected, isTrue);
await confirm(tester);
expect(sel.start, DateTime(2026, 4, 30));
});
testWidgets('arrows move by a day and a week, Enter confirms', (
tester,
) async {
final popped = await _open(tester, _sel());
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(cell(tester, DateTime(2026, 3, 18)).selected, isTrue);
expect(cell(tester, DateTime(2026, 3, 18)).focused, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(popped.value, DateTime(2026, 3, 10));
});
testWidgets('a mouse click selects a day', (tester) async {
final sel = _sel();
await _open(tester, sel);
await tester.tap(
find.byKey(ValueKey(DateTime(2026, 3, 3))),
kind: PointerDeviceKind.mouse,
);
await tester.pump();
expect(sel.start, DateTime(2026, 3, 3));
});
testWidgets('the year wheel: mouse drag scrolls, a click selects, no wrap', (
tester,
) async {
final sel = _sel();
await _open(tester, sel);
await tester.drag(
find.byType(YearWheel),
const Offset(0, -45),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(sel.year, 2027);
expect(sel.start, DateTime(2027, 3, 10));
await tester.tap(
find.byKey(const ValueKey('wheel-2026')),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(sel.year, 2026);
await tester.drag(find.byType(YearWheel), const Offset(0, -400));
await tester.pumpAndSettle();
expect(sel.year, 2027, reason: 'the last year; the wheel does not loop');
await tester.tap(find.byKey(const ValueKey('wheel-2028')));
await tester.pumpAndSettle();
expect(sel.year, 2027, reason: '2028 is past lastDate: dimmed, inert');
await tester.drag(find.byType(YearWheel), const Offset(0, 400));
await tester.pumpAndSettle();
expect(sel.year, 2024, reason: 'the first year; no wrap to 2027');
});
testWidgets('the birth-date flow opens on the year wheel', (tester) async {
final sel = _sel();
await _open(tester, sel, startOnYear: true);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(sel.year, 2025, reason: 'the arrow went to the wheel');
expect(sel.start, DateTime(2025, 3, 10));
});
testWidgets('icons and cells speak; nothing has a tooltip', (tester) async {
await _open(tester, _sel());
expect(byIconLabel('Confirm'), findsOneWidget);
expect(byIconLabel('Cancel'), findsOneWidget);
expect(bySemLabel('Tuesday, March 10, 2026'), findsOneWidget);
expect(bySemLabel('Year'), findsOneWidget);
expect(find.byType(Tooltip), findsNothing);
});
testWidgets('a screen reader can tap an in-range day, not an inert one', (
tester,
) async {
final handle = tester.ensureSemantics();
await _open(tester, _sel(first: DateTime(2026, 3, 5)));
SemanticsData day(int d) => tester
.getSemantics(find.byKey(ValueKey(DateTime(2026, 3, d))))
.getSemanticsData();
expect(day(12).hasAction(SemanticsAction.tap), isTrue);
expect(day(4).hasAction(SemanticsAction.tap), isFalse);
handle.dispose();
});
}

View File

@ -1,123 +0,0 @@
// The range mode of the date dialog: two taps, shaded span, filled ends,
// the check gated on both taps, presets, restarts and navigation.
import 'package:diet_guard_app/widgets/pickers/date_dialog.dart';
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'picker_test_support.dart';
DateSelection _sel([DateTimeRange? initial]) => DateSelection.range(
first: DateTime(2026),
last: DateTime(2026, 12, 31),
initial:
initial ??
DateTimeRange(start: DateTime(2026, 3, 10), end: DateTime(2026, 3, 10)),
);
Future<Popped> _open(WidgetTester tester, DateSelection sel) =>
openDialog(tester, DateDialog(selection: sel, today: DateTime(2026, 3)));
Future<void> _tapDay(WidgetTester tester, int day) async {
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, day))));
await tester.pump();
}
void main() {
testWidgets('two taps: check off after one, span shaded after two', (
tester,
) async {
final popped = await _open(
tester,
DateSelection.range(first: DateTime(2026), last: DateTime(2026, 12, 31)),
);
expect(confirmButton(tester).onPressed, isNull);
await tester.tap(find.byKey(const ValueKey('month-3')));
await tester.pump();
await _tapDay(tester, 4);
expect(confirmButton(tester).onPressed, isNull, reason: 'one tap');
expect(cell(tester, DateTime(2026, 3, 4)).selected, isTrue);
await _tapDay(tester, 9);
expect(confirmButton(tester).onPressed, isNotNull);
expect(cell(tester, DateTime(2026, 3, 9)).selected, isTrue, reason: 'cap');
expect(cell(tester, DateTime(2026, 3, 6)).band, isTrue);
expect(cell(tester, DateTime(2026, 3, 6)).selected, isFalse);
expect(cell(tester, DateTime(2026, 3, 10)).band, isFalse);
await confirm(tester);
final range = popped.value! as DateTimeRange;
expect(range.start, DateTime(2026, 3, 4));
expect(range.end, DateTime(2026, 3, 9));
});
testWidgets('later day first still gives an ordered range', (tester) async {
final popped = await _open(tester, _sel());
await _tapDay(tester, 20);
await _tapDay(tester, 12);
await confirm(tester);
final range = popped.value! as DateTimeRange;
expect((range.start.day, range.end.day), (12, 20));
});
testWidgets('the same day twice is a valid one-day range', (tester) async {
final popped = await _open(tester, _sel());
await _tapDay(tester, 5);
await _tapDay(tester, 5);
expect(cell(tester, DateTime(2026, 3, 5)).band, isFalse);
await confirm(tester);
final range = popped.value! as DateTimeRange;
expect(range.start, DateTime(2026, 3, 5));
expect(range.end, DateTime(2026, 3, 5));
});
testWidgets('a third tap restarts and turns the check off', (tester) async {
await _open(tester, _sel());
await _tapDay(tester, 2);
await _tapDay(tester, 6);
await _tapDay(tester, 25);
expect(confirmButton(tester).onPressed, isNull);
expect(cell(tester, DateTime(2026, 3, 25)).selected, isTrue);
expect(cell(tester, DateTime(2026, 3, 4)).band, isFalse);
});
testWidgets('a preset range opens shaded with the check on', (tester) async {
final sel = _sel(
DateTimeRange(start: DateTime(2026, 3, 2), end: DateTime(2026, 3, 9)),
);
await _open(tester, sel);
expect(confirmButton(tester).onPressed, isNotNull);
expect(cell(tester, DateTime(2026, 3, 5)).band, isTrue);
await tester.tap(find.byKey(const ValueKey('month-7')));
await tester.pumpAndSettle();
expect(sel.start, DateTime(2026, 3, 2));
expect(sel.end, DateTime(2026, 3, 9));
expect(confirmButton(tester).onPressed, isNotNull);
});
testWidgets('keyboard: arrows move the cursor, Space taps, Enter waits '
'for both ends', (tester) async {
final popped = await _open(tester, _sel());
await _tapDay(tester, 10);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(popped.closed, isFalse, reason: 'one end only');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
final range = popped.value! as DateTimeRange;
expect((range.start.day, range.end.day), (10, 12));
});
testWidgets('Esc cancels a half-chosen range', (tester) async {
final popped = await _open(tester, _sel());
await _tapDay(tester, 3);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(popped.closed, isTrue);
expect(popped.value, isNull);
});
}

View File

@ -1,155 +0,0 @@
// The date picker's selection rules, as plain unit tests.
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
final _first = DateTime(2026, 1, 15);
final _last = DateTime(2026, 6, 10);
DateSelection _single(DateTime initial) =>
DateSelection.single(first: _first, last: _last, initial: initial);
DateSelection _range([DateTimeRange? initial]) =>
DateSelection.range(first: _first, last: _last, initial: initial);
void main() {
group('bounds', () {
test('an initial day outside the bounds is clamped in', () {
expect(_single(DateTime(2025, 12)).start, _first);
expect(_single(DateTime(2027)).start, _last);
final s = _single(DateTime(2027));
expect((s.year, s.month), (2026, 6));
});
test('a time of day is dropped from every date', () {
final s = DateSelection.single(
first: DateTime(2026, 1, 1, 13),
last: DateTime(2026, 2, 1, 23, 59),
initial: DateTime(2026, 1, 5, 18),
);
expect(s.first, DateTime(2026));
expect(s.last, DateTime(2026, 2));
expect(s.start, DateTime(2026, 1, 5));
});
test('out-of-range days are inert', () {
final s = _single(DateTime(2026, 3, 3))..tap(DateTime(2026, 1, 14));
expect(s.start, DateTime(2026, 3, 3));
});
test('a month is allowed when any of its days is in range', () {
final s = _single(DateTime(2026, 3, 3));
expect(s.monthAllowed(2026, 1), isTrue);
expect(s.monthAllowed(2026, 6), isTrue);
expect(s.monthAllowed(2026, 7), isFalse);
expect(s.monthAllowed(2025, 12), isFalse);
});
});
group('single', () {
test('a tap selects without confirming; the check is always on', () {
final s = _single(DateTime(2026, 3, 3))..tap(DateTime(2026, 3, 9));
expect(s.start, DateTime(2026, 3, 9));
expect(s.canConfirm, isTrue);
});
test('a 31st moved to a 30-day month lands on the 30th', () {
final s = _single(DateTime(2026, 3, 31))..showMonth(4);
expect(s.start, DateTime(2026, 4, 30));
});
test('a month change clamps the day into the bounds', () {
final s = _single(DateTime(2026, 3, 20))..showMonth(6);
expect(s.start, _last);
s.showMonth(1);
expect(s.start, _first, reason: 'day 10 of January is before the 15th');
});
test('a year change pulls the month into the edge year', () {
final s = DateSelection.single(
first: DateTime(2025, 9),
last: DateTime(2026, 3, 31),
initial: DateTime(2025, 11, 4),
)..showYear(2026);
expect((s.year, s.month), (2026, 3));
expect(s.start, DateTime(2026, 3, 4));
s.showYear(2025);
expect((s.year, s.month), (2025, 9));
});
test('arrows move the selection, across months, stopping at bounds', () {
final s = _single(DateTime(2026, 1, 31))..moveCursor(1);
expect(s.start, DateTime(2026, 2));
expect(s.month, 2);
s.moveCursor(-7);
expect(s.start, DateTime(2026, 1, 25));
s
..moveCursor(-7)
..moveCursor(-7);
expect(s.start, DateTime(2026, 1, 18), reason: 'the 11th is out');
});
});
group('range', () {
test('one tap leaves the check off; two taps turn it on', () {
final s = _range()..tap(DateTime(2026, 3, 2));
expect(s.canConfirm, isFalse);
s.tap(DateTime(2026, 3, 9));
expect(s.canConfirm, isTrue);
expect(s.dateRange!.start, DateTime(2026, 3, 2));
expect(s.dateRange!.end, DateTime(2026, 3, 9));
});
test('a later day tapped first still gives an ordered range', () {
final s = _range()
..tap(DateTime(2026, 3, 9))
..tap(DateTime(2026, 3, 2));
expect(s.dateRange!.start, DateTime(2026, 3, 2));
expect(s.dateRange!.end, DateTime(2026, 3, 9));
});
test('the same day twice is a one-day range', () {
final s = _range()
..tap(DateTime(2026, 3, 4))
..tap(DateTime(2026, 3, 4));
expect(s.canConfirm, isTrue);
expect(s.dateRange!.duration, Duration.zero);
});
test('a third tap restarts from that day', () {
final s = _range()
..tap(DateTime(2026, 3, 2))
..tap(DateTime(2026, 3, 9))
..tap(DateTime(2026, 4));
expect(s.start, DateTime(2026, 4));
expect(s.end, isNull);
expect(s.canConfirm, isFalse);
});
test('a preset range counts as both taps', () {
final s = _range(
DateTimeRange(start: DateTime(2026, 2, 2), end: DateTime(2026, 2, 8)),
);
expect(s.canConfirm, isTrue);
expect((s.year, s.month), (2026, 2));
});
test('navigating never moves the chosen ends', () {
final s = _range()
..tap(DateTime(2026, 3, 31))
..tap(DateTime(2026, 4, 2))
..showMonth(2)
..showYear(2026)
..moveCursor(3);
expect(s.start, DateTime(2026, 3, 31));
expect(s.end, DateTime(2026, 4, 2));
});
test('with no preset the cursor starts on today, clamped', () {
final s = _range();
expect(s.start, isNull);
expect(s.allows(s.cursor), isTrue);
});
});
}

View File

@ -1,183 +0,0 @@
// Guard: every date, date-range and time picker the app opens paints no
// words -- only numerals and punctuation. Each picker is opened through its
// real call site, so a call site that slips back to a framework picker (which
// paints "Select date", "Cancel", "OK") turns this red.
import 'dart:io';
import 'package:diet_guard_app/screens/log_meal_screen.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/document_store_io.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:diet_guard_app/widgets/body/dated_value_card.dart';
import 'package:diet_guard_app/widgets/body/profile_card.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sheet.dart';
import 'package:diet_guard_app/widgets/meal_time_dropdown.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import '../no_words_support.dart';
import '../screens/body_screen_support.dart';
Future<void> _host(WidgetTester tester, Widget child) async {
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: child)));
await tester.pumpAndSettle();
}
/// Opens the picker behind [control] and asserts the open dialog paints at
/// least [minStrings] strings, none of them a word, and no tooltip.
Future<void> _expectWordless(
WidgetTester tester,
Finder control, {
required int minStrings,
required String where,
}) async {
await tester.tap(control);
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsOneWidget, reason: '$where: no dialog');
expectNoWords(tester, minStrings: minStrings, where: where);
expect(
find.descendant(of: find.byType(Dialog), matching: find.byType(Tooltip)),
findsNothing,
reason: '$where: a tooltip paints its words on long-press',
);
}
FilterSheet _sheet(DateTimeRange? range) => FilterSheet(
filter: HistoryFilter(dateRange: range),
sortField: HistorySortField.date,
ascending: false,
maxKcal: 1000,
maxProtein: 100,
maxCarbs: 100,
maxFat: 100,
onFilterChanged: (_) {},
onSortChanged: ({required field, required asc}) {},
onApply: () {},
onClear: () {},
);
void main() {
testWidgets('meal-time exact picker', (tester) async {
await _host(
tester,
MealTimeDropdown(
icon: Icons.wb_sunny,
semanticLabel: 'First meal',
value: 480,
minMinute: 0,
maxMinute: 1424,
onChanged: (_) {},
),
);
await _expectWordless(
tester,
byIconLabel('Type or pick an exact time'),
minStrings: 2,
where: 'time',
);
});
for (final range in [
null,
DateTimeRange(start: DateTime(2026, 3, 2), end: DateTime(2026, 3, 9)),
]) {
testWidgets('history date-range picker (preset: ${range != null})', (
tester,
) async {
await _host(tester, _sheet(range));
await _expectWordless(
tester,
find.byIcon(Icons.date_range),
minStrings: 28,
where: 'range',
);
});
}
testWidgets('body dated-value day picker', (tester) async {
await _host(
tester,
DatedValueCard(
semanticLabel: 'Weight',
fieldSemanticLabel: 'Weight (kg)',
icon: Icons.monitor_weight,
unit: 'kg',
min: 20,
max: 400,
latest: null,
series: const [],
onSave: (_, _) async {},
onDelete: (_) async {},
onChanged: () {},
),
);
await _expectWordless(
tester,
find.byIcon(Icons.event),
minStrings: 28,
where: 'dated value',
);
});
testWidgets('profile birth-date picker', (tester) async {
final store = MemoryDocStore();
await setUpBodyServices(store);
addTearDown(tearDownBodyServices);
await _host(tester, ProfileCard(onSaved: () {}));
await _expectWordless(
tester,
byIconLabel('Birth date'),
minStrings: 12,
where: 'birth',
);
});
group('log meal', () {
late Directory dir;
setUp(() async {
dir = await Directory.systemTemp.createTemp('dg_pickers_');
LogStorageService.resetForTesting(store: FileDocumentStore(dir));
FoodBankService.resetForTesting(store: FileDocumentStore(dir));
AppSettingsService.resetForTesting(store: FileDocumentStore(dir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(dir));
await MealScheduleService.initForTesting(FileDocumentStore(dir));
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
MealScheduleService.resetForTesting();
await dir.delete(recursive: true);
});
_logLater();
});
}
void _logLater() {
testWidgets('log-for-later date picker', (tester) async {
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
});
await _expectWordless(
tester,
controlByLabel('Log for later'),
minStrings: 28,
where: 'log later',
);
});
}

View File

@ -1,73 +0,0 @@
// The Body screen's two date controls apply what their picker confirms:
// the dated-value card's day, and the profile's birth date (whose picker
// opens with the year wheel focused).
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/widgets/body/dated_value_card.dart';
import 'package:diet_guard_app/widgets/body/profile_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import '../screens/body_screen_support.dart';
Future<void> _host(WidgetTester tester, Widget child) async {
tester.view.physicalSize = const Size(800, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: child)));
await tester.pumpAndSettle();
}
Future<void> _pressAndSettle(
WidgetTester tester,
LogicalKeyboardKey key,
) async {
await tester.sendKeyEvent(key);
await tester.pumpAndSettle();
}
void main() {
testWidgets('the dated-value card shows the confirmed day', (tester) async {
await _host(
tester,
DatedValueCard(
semanticLabel: 'Weight',
fieldSemanticLabel: 'Weight (kg)',
icon: Icons.monitor_weight,
unit: 'kg',
min: 20,
max: 400,
latest: null,
series: const [],
onSave: (_, _) async {},
onDelete: (_) async {},
onChanged: () {},
),
);
final now = DateTime.now();
final yesterday = localDateKey(DateTime(now.year, now.month, now.day - 1));
await tester.tap(find.byIcon(Icons.event));
await tester.pumpAndSettle();
await _pressAndSettle(tester, LogicalKeyboardKey.arrowLeft);
await _pressAndSettle(tester, LogicalKeyboardKey.enter);
expect(find.byType(Dialog), findsNothing);
expect(find.text(' $yesterday'), findsOneWidget);
});
testWidgets('the birth date opens on the year wheel and applies', (
tester,
) async {
await setUpBodyServices(MemoryDocStore());
addTearDown(tearDownBodyServices);
await _host(tester, ProfileCard(onSaved: () {}));
final year = DateTime.now().year - 31;
await tester.tap(byIconLabel('Birth date'));
await tester.pumpAndSettle();
await _pressAndSettle(tester, LogicalKeyboardKey.arrowUp);
await _pressAndSettle(tester, LogicalKeyboardKey.enter);
expect(find.byType(Dialog), findsNothing);
expect(find.text(' $year-01-01'), findsOneWidget);
});
}

View File

@ -1,84 +0,0 @@
// Shared helpers for the picker widget tests: open a dialog from a button and
// read back what it popped, and find cells by state rather than by text.
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
/// What a dialog popped: [closed] turns true once it is gone.
class Popped {
/// The popped value (null when cancelled).
Object? value;
/// Whether the dialog has closed.
bool closed = false;
}
/// Pumps an app whose one button opens [dialog]; taps it and settles.
Future<Popped> openDialog(
WidgetTester tester,
Widget dialog, {
Size size = const Size(500, 900),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final popped = Popped();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.add, semanticLabel: 'open'),
onPressed: () async {
popped
..value = await showDialog<Object>(
context: context,
builder: (_) => dialog,
)
..closed = true;
},
),
),
),
),
);
await tester.tap(byIconLabel('open'));
await tester.pumpAndSettle();
return popped;
}
/// The cell keyed [key] (a `DateTime` day, or `'month-<m>'`).
NumeralCell cell(WidgetTester tester, Object key) => tester.widget<NumeralCell>(
find.byKey(
key is DateTime ? ValueKey<DateTime>(key) : ValueKey<String>('$key'),
),
);
/// The check button, enabled or not.
IconButton confirmButton(WidgetTester tester) => tester.widget<IconButton>(
find.ancestor(of: byIconLabel('Confirm'), matching: find.byType(IconButton)),
);
/// Scrolls the open time dialog's wheels to [hour] and [minute] (a disabled
/// value springs back) and settles. Does not confirm.
Future<void> setWheels(WidgetTester tester, int hour, int minute) async {
for (final (key, value) in [('hour', hour), ('minute', minute)]) {
final wheel = tester.widget<ListWheelScrollView>(
find.descendant(
of: find.byKey(ValueKey(key)),
matching: find.byType(ListWheelScrollView),
),
);
(wheel.controller! as FixedExtentScrollController).jumpToItem(value);
await tester.pumpAndSettle();
}
}
/// Taps the check and settles.
Future<void> confirm(WidgetTester tester) async {
await tester.tap(controlByLabel('Confirm'));
await tester.pumpAndSettle();
}

View File

@ -1,156 +0,0 @@
// The time dialog: two looping 24 h wheels, bounds dimmed and inert,
// keyboard, mouse, confirm vs cancel.
import 'package:diet_guard_app/widgets/pickers/time_dialog.dart';
import 'package:diet_guard_app/widgets/pickers/time_wheels.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import 'picker_test_support.dart';
Future<Popped> _open(
WidgetTester tester, {
int initial = 8 * 60,
int min = 0,
int max = 1439,
}) => openDialog(
tester,
TimeDialog(initialMinute: initial, minMinute: min, maxMinute: max),
);
Finder _wheel(String which) => find.byKey(ValueKey(which));
/// Moves keyboard focus to the minute wheel (the hour wheel has it first).
Future<void> _focusMinutes(WidgetTester tester) async {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
TimeOfDay _value(Popped p) => p.value! as TimeOfDay;
void main() {
test('hourAllowed and clampMinute', () {
expect(hourAllowed(7, 480, 1200), isFalse);
expect(hourAllowed(8, 495, 1200), isTrue);
expect(hourAllowed(20, 480, 1200), isTrue);
expect(hourAllowed(21, 480, 1200), isFalse);
expect(clampMinute(10, 480, 1200), 480);
expect(clampMinute(1300, 480, 1200), 1200);
});
testWidgets('wheels show 24 h numerals and confirm the centre', (
tester,
) async {
final popped = await _open(tester, initial: 13 * 60 + 5);
expect(find.byKey(const ValueKey('wheel-13')), findsWidgets);
expect(find.text('13'), findsWidgets);
expect(find.text('05'), findsWidgets);
await confirm(tester);
expect(_value(popped), const TimeOfDay(hour: 13, minute: 5));
});
testWidgets('arrows loop: hour 23 -> 00, minute 59 -> 00', (tester) async {
final popped = await _open(tester, initial: 23 * 60 + 59);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await _focusMinutes(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(_value(popped), const TimeOfDay(hour: 0, minute: 0));
});
testWidgets('mouse drag scrolls a wheel; a click selects a number', (
tester,
) async {
final popped = await _open(tester, initial: 10 * 60);
await tester.drag(
_wheel('hour'),
const Offset(0, -85),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('wheel-58')),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
await confirm(tester);
expect(_value(popped), const TimeOfDay(hour: 12, minute: 58));
});
testWidgets('out-of-range values are inert; an hour change clamps the '
'minute into range', (tester) async {
final popped = await _open(tester, initial: 9 * 60 + 5, min: 8 * 60 + 15);
// 07 is dimmed: a tap does nothing.
await tester.tap(find.byKey(const ValueKey('wheel-7')).first);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
await confirm(tester);
expect(
_value(popped),
const TimeOfDay(hour: 8, minute: 15),
reason: '09:05 -> 08 pulls :05 up to :15; 07 refused',
);
});
testWidgets('a wheel dragged onto a dimmed value springs back', (
tester,
) async {
final popped = await _open(tester, initial: 20 * 60, max: 20 * 60);
await tester.drag(_wheel('hour'), const Offset(0, -85));
await tester.pumpAndSettle();
await confirm(tester);
expect(_value(popped), const TimeOfDay(hour: 20, minute: 0));
});
testWidgets('an initial value outside the range opens clamped', (
tester,
) async {
final popped = await _open(tester, initial: 6 * 60, min: 8 * 60 + 15);
await confirm(tester);
expect(_value(popped), const TimeOfDay(hour: 8, minute: 15));
});
for (final how in ['close icon', 'outside tap', 'Esc']) {
testWidgets('$how cancels', (tester) async {
final popped = await _open(tester);
switch (how) {
case 'close icon':
await tester.tap(controlByLabel('Cancel'));
case 'outside tap':
await tester.tapAt(const Offset(4, 4));
default:
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
}
await tester.pumpAndSettle();
expect(popped.closed, isTrue);
expect(popped.value, isNull);
});
}
testWidgets('Enter on the focused close icon cancels', (tester) async {
final popped = await _open(tester);
await _focusMinutes(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(popped.closed, isTrue);
expect(popped.value, isNull);
});
testWidgets('wheels speak; no tooltip', (tester) async {
await _open(tester);
expect(bySemLabel('Hour'), findsOneWidget);
expect(bySemLabel('Minute'), findsOneWidget);
expect(find.byType(Tooltip), findsNothing);
});
}

View File

@ -125,25 +125,6 @@ void main() {
});
});
group('pickers paint no words', () {
// The framework pickers paint "Select date", "Cancel", "OK", weekday and
// month names and tooltips; lib/widgets/pickers/ replaces all three.
test('no showDatePicker, showDateRangePicker or showTimePicker', () {
final banned = RegExp(r'show(Date|DateRange|Time)Picker\(');
final offenders = [
for (final file in _dartFilesIn('lib'))
if (banned.hasMatch(file.readAsStringSync()))
file.path.split('/app/lib/').last,
];
expect(
offenders,
isEmpty,
reason: 'A framework picker came back. Use pickDate, pickDateRange '
'or pickTime from widgets/pickers/pickers.dart.',
);
});
});
group('singletons that silently no-op are initialised at startup', () {
// Every catering entry point checks `isInitialized` first and returns
// quietly when it is false, so a missing `init()` does not throw -- it

View File

@ -70,24 +70,16 @@ Future<void> seedPerson(MemoryDocStore store, {Object? goal}) async {
'kind': 'run',
'min': 60,
'km': 10,
'label': 'Running 10.0 km',
'label': 'Evening run',
'src': 'runnerup',
},
{
'day': yesterdayKey(),
'kind': 'strength',
'min': 80,
'label': 'StrongLifts workout A (80 min, all succeeded)',
'label': 'StrongLifts A',
'src': 'screen-locker',
},
{
'day': yesterdayKey(),
'kind': 'walk',
'min': 30,
'km': 2,
'label': 'walk to the shop',
'src': 'manual',
},
],
},
});

View File

@ -133,15 +133,8 @@ void main() {
byIconLabel('Mean daily exercise, last 1 full days:'),
findsOneWidget,
);
expect(
byIconLabel('Running 10.0 km'),
findsOneWidget,
);
expect(
byIconLabel('StrongLifts workout A (80 min, all succeeded)'),
findsOneWidget,
);
expect(find.textContaining('walk to the shop'), findsOneWidget);
expect(find.textContaining('Evening run'), findsOneWidget);
expect(find.textContaining('StrongLifts A'), findsOneWidget);
expect(byIconLabel('Connect Health Connect steps'), findsNothing);
});

View File

@ -17,7 +17,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import '../e2e_github_header.dart';
import '../fake_secure_storage.dart';
import '../label_finders.dart';
@ -97,7 +96,7 @@ void main() {
testWidgets('device flow happy path saves the token and syncs', (
tester,
) async {
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
@ -151,7 +150,7 @@ void main() {
testWidgets('device flow connects but surfaces a post-connect sync failure', (
tester,
) async {
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({

View File

@ -144,21 +144,19 @@ void main() {
);
expect(find.text('WXYZ-1234'), findsOneWidget);
await pumpUntil(tester, () => _errorIcon().evaluate().isNotEmpty);
await pumpUntil(
tester,
() => find.textContaining('access_denied').evaluate().isNotEmpty,
);
expect(_errorIcon(), findsOneWidget);
expect(find.textContaining('access_denied'), findsOneWidget);
await tester.tap(byIconLabel('Open GitHub & copy code'));
await tester.tap(find.text('Open GitHub & copy code'));
await tester.pump();
expect(launcher.launched, 'https://github.com/login/device');
await tester.tap(byIconLabel('Cancel'));
await tester.tap(find.text('Cancel'));
await settle(tester);
});
});
}
/// The dialog's error glyph, whose spoken label carries GitHub's error.
Finder _errorIcon() => find.byWidgetPredicate(
(w) => w is Icon && (w.semanticLabel ?? '').contains('access_denied'),
);

View File

@ -17,7 +17,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import '../e2e_github_header.dart';
import '../fake_secure_storage.dart';
import '../label_finders.dart';
@ -121,7 +120,7 @@ void main() {
});
});
testWidgets('Test connection reports success', (tester) async {
final mock = e2eMock((_) async => http.Response('{}', 200));
final mock = MockClient((_) async => http.Response('{}', 200));
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
@ -135,7 +134,7 @@ void main() {
});
});
testWidgets('Test connection reports failure', (tester) async {
final mock = e2eMock((_) async => http.Response('', 403));
final mock = MockClient((_) async => http.Response('', 403));
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
@ -149,7 +148,7 @@ void main() {
});
});
testWidgets('Sync now runs a sync tick and reports success', (tester) async {
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
// A bare `/repos/<owner>/<repo>` GET is crdt_sync's GitHubClient
// probing whether the repo itself exists (vs. a content path just
@ -183,7 +182,7 @@ void main() {
await SyncHealth.recordFailure();
expect((await SyncHealth.read()).failureKind, SyncFailureKind.failed);
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
return http.Response('{}', 200);

View File

@ -163,20 +163,19 @@ void main() {
await tester.tap(controlByLabel('Date range'));
await settle(tester);
// The picker opens on the current month (a null `initialDateRange`)
// and its `lastDate` is capped at tomorrow, so days 10+ aren't always
// selectable this early in a month. Days 1 and 2 of the displayed
// month are always within [firstDate, lastDate] regardless of which
// day it is when the test runs.
// The picker opens on the current month (no `currentDate` override
// and a null `initialDateRange`) and its `lastDate` is capped at
// tomorrow, so days "10"+ aren't always selectable this early in a
// month. Days "1" and "2" of the displayed month are always within
// [firstDate, lastDate] regardless of which day it is when the test
// runs.
final now = DateTime.now();
final expectedStart = DateTime(now.year, now.month);
await tester.tap(find.byKey(ValueKey(expectedStart)));
await tester.tap(find.text('1').last);
await settle(tester);
await tester.tap(
find.byKey(ValueKey(DateTime(now.year, now.month, 2))),
);
await tester.tap(find.text('2').last);
await settle(tester);
await tester.tap(controlByLabel('Confirm'));
await tester.tap(find.text('Save'));
await settle(tester);
// After a successful selection the filter button label shows the

View File

@ -64,7 +64,7 @@ void main() {
await settle(tester);
// The date picker's initialDate is already tomorrow, so confirming
// immediately picks it without touching a specific calendar cell.
await tester.tap(controlByLabel('Confirm'));
await tester.tap(find.text('OK'));
await settle(tester);
final tomorrowKey = localDateKey(tomorrow());
@ -106,7 +106,7 @@ void main() {
await tester.tap(controlByLabel('Log for later'));
await settle(tester);
await tester.tap(controlByLabel('Confirm'));
await tester.tap(find.text('OK'));
await settle(tester);
await tester.enterText(find.byType(TextField).at(0), 'no slot meal');
@ -131,7 +131,7 @@ void main() {
await tester.tap(controlByLabel('Log for later'));
await settle(tester);
await tester.tap(controlByLabel('Confirm'));
await tester.tap(find.text('OK'));
await settle(tester);
expect(find.text(localDateKey(tomorrow())), findsOneWidget);

View File

@ -14,11 +14,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart' show SyncSettingsScreen;
import '../fake_secure_storage.dart';
import '../label_finders.dart';
import '../pickers/picker_test_support.dart';
void main() {
late Directory tempDir;
@ -90,8 +88,15 @@ void main() {
await tester.tap(byIconLabel('Type or pick an exact time').first);
await settle(tester);
await setWheels(tester, 7, 23);
await tester.tap(controlByLabel('Confirm'));
await tester.tap(find.byIcon(Icons.keyboard_outlined));
await settle(tester);
final fields = find.descendant(
of: find.byType(TimePickerDialog),
matching: find.byType(TextField),
);
await tester.enterText(fields.at(0), '07');
await tester.enterText(fields.at(1), '23');
await tester.tap(find.text('OK'));
await settle(tester);
// Through the real _applySchedule: normalised, recorded, persisted.
@ -180,7 +185,7 @@ void main() {
await tester.tap(byIconLabel('Sync settings'));
await settle(tester);
expect(find.byType(SyncSettingsScreen), findsOneWidget);
expect(find.text('Firebase sync'), findsOneWidget);
});
});
testWidgets('Sync settings wires storedAccount, not loadAccount, as the '
@ -207,7 +212,7 @@ void main() {
await settle(tester);
expect(find.text('sync@example.com'), findsOneWidget);
expect(byIconLabel('Disconnect'), findsOneWidget);
expect(find.text('Disconnect'), findsOneWidget);
});
});
testWidgets(

View File

@ -20,7 +20,6 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../e2e_github_header.dart';
import '../fake_secure_storage.dart';
void main() {
@ -56,7 +55,7 @@ void main() {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
var puts = 0;
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') puts++;
return http.Response('', 404);
});
@ -74,7 +73,7 @@ void main() {
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var puts = 0;
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') {
puts++;
return http.Response('{}', 200); // Contents-API PUT succeeded
@ -113,7 +112,7 @@ void main() {
resetSyncDeviceIdForTest();
installFakeSecureStorage(initial: {'sync.token': 't'});
final pushedPaths = <String>[];
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') {
pushedPaths.add(req.url.path);
return http.Response('{}', 200);

View File

@ -26,7 +26,6 @@ import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fake_notifications.dart';
import '../e2e_github_header.dart';
import '../fake_secure_storage.dart';
const _manual = Nutrition(
@ -136,7 +135,7 @@ void main() {
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var requests = 0;
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
requests++;
return http.Response('', 404);
});
@ -158,7 +157,7 @@ void main() {
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var requests = 0;
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
requests++;
return http.Response('', 404);
});
@ -196,15 +195,15 @@ void main() {
'"source":"manual","slot":12,"day":"$day"},'
'"${day}T11:30:00.000Z-0000-pc"]},"deleted":false,'
'"deleted_hlc":null}}';
final mock = e2eMock((req) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
return http.Response('{}', 200);
}
if (req.url.path.endsWith('/devices-e2e')) {
if (req.url.path.endsWith('/devices')) {
return http.Response('[{"name":"pc","type":"dir"}]', 200);
}
if (req.url.path.endsWith('/devices-e2e/pc/food_log.json')) {
if (req.url.path.endsWith('/devices/pc/food_log.json')) {
// The Contents API returns base64 in a `content` field, not raw JSON.
return http.Response(
jsonEncode({'content': base64.encode(utf8.encode(remote))}),

View File

@ -34,23 +34,6 @@ void main() {
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
});
test('init renames the reminder channel to the app name', () async {
final log = installFakeAndroidNotifications();
NotificationService.resetForTesting(
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
);
await NotificationService.init();
final createCall = log.firstWhere(
(c) => c.method == 'createNotificationChannel',
orElse: () => throw StateError('no createNotificationChannel call logged'),
);
final args = createCall.arguments as Map;
expect(args['id'], reminderChannelId);
expect(args['name'], 'DietGuard');
});
test('requestPermission delegates to the Android implementation', () async {
installFakeAndroidNotifications();
NotificationService.resetForTesting(

View File

@ -9,7 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'body_test_support.dart';
const _dir = 'diet-guard-sync/devices-e2e';
const _dir = 'diet-guard-sync/devices';
void main() {
tearDown(BodyService.resetForTesting);
@ -39,7 +39,7 @@ void main() {
);
final remote = MemoryRemote({
'$_dir/pc-uuid/body.json': encodeBodyForPush(bodyToLog(peer)),
'wake-alarm-sync/devices-e2e/phone/alarm.json': jsonEncode({
'wake-alarm-sync/devices/phone/alarm.json': jsonEncode({
'alarm': {
'fields': {
'morning_sessions': [
@ -53,7 +53,7 @@ void main() {
}),
});
await syncBody(remote, devicesDir: _dir, wakeAlarm: remote);
await syncBody(remote, devicesDir: _dir);
final body = BodyService.instance;
expect(body.birth, '1995-03-01');

View File

@ -1,165 +0,0 @@
// End-to-end encryption of the diet_guard sync tick (`sync_e2e.dart`).
//
// The fake GitHub stores what is PUT, so the header written on first use
// reads back and the sealed blobs are the bytes a real backend would hold.
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart' show E2eLockReason, RemoteStore;
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/document_store_io.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_e2e.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:flutter_test/flutter_test.dart';
import '../fake_secure_storage.dart';
import 'sync_service_test_support.dart';
const _new = 'diet-guard-sync/devices-e2e';
const _old = 'diet-guard-sync/devices';
String _text(Map<String, dynamic> put) =>
utf8.decode(base64.decode(put['content'] as String));
void main() {
late Directory tempDir;
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
installFakeSecureStorage();
tempDir = await Directory.systemTemp.createTemp('diet_guard_e2e_test_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
test('the pushed food log holds no plaintext field', () async {
await LogStorageService.instance.logMeal('secret oatmeal', manualNutrition);
final fake = FakeGitHub();
await runSync(fake.buildClient());
final blob = _text(fake.putsByPath['$_new/phone/food_log.json']!);
expect(blob, isNot(contains('secret')));
expect(blob, isNot(contains('"fields"')));
expect(blob, isNot(contains('kcal')));
expect(await fake.opened('$_new/phone/food_log.json'), contains('secret'));
});
test('nothing is written to the old plaintext prefix or revs', () async {
await LogStorageService.instance.logMeal('oatmeal', manualNutrition);
final fake = FakeGitHub();
await runSync(fake.buildClient());
expect(fake.putsByPath, isNotEmpty);
for (final path in fake.putsByPath.keys) {
expect(path, isNot(startsWith('$_old/')));
expect(path, isNot(startsWith('diet-guard-sync/revs/')));
}
expect(fake.putsByPath, contains('diet-guard-sync/revs-e2e/phone'));
});
test('export keys are pushed sealed, never to the old node', () async {
await defaultKeyring().ensureNewest();
final fake = FakeGitHub();
await runSync(fake.buildClient());
final sealed = fake.putsByPath['$_new/phone/export_keys.json'];
expect(sealed, isNotNull);
final opened = (await fake.opened('$_new/phone/export_keys.json'))!;
final record = (jsonDecode(opened) as Map).values.single as Map;
final keyB64 = ((record['fields'] as Map)['k'] as List).first as String;
expect(_text(sealed!), isNot(contains(keyB64)));
expect(fake.putsByPath, isNot(contains('$_old/phone/export_keys.json')));
});
test('an old plaintext peer log is copied, merged, and left alone', () async {
await LogStorageService.instance.logMeal('peer porridge', manualNutrition);
final local = await LogStorageService.instance.readLog();
final peerLog = encodeLogForPush(dayLogToLog(local));
await LogStorageService.instance.writeLog(const {});
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: {'$_old/pc/food_log.json': peerLog},
);
final merged = await runSync(fake.buildClient());
final descs = merged.values.expand((e) => e).map((e) => e.desc);
expect(descs, contains('peer porridge'));
expect(
_text(fake.putsByPath['$_new/pc/food_log.json']!),
isNot(contains('porridge')),
);
expect(fake.files['$_old/pc/food_log.json'], peerLog);
});
test('no passphrase: locked, nothing read or written', () async {
installFakeSecureStorage(initial: const {'crdt_sync_e2e_passphrase': ''});
final fake = FakeGitHub();
await expectLater(
runSync(fake.buildClient()),
throwsA(isA<SyncLockedException>()),
);
expect(fake.puts, isEmpty);
expect(syncLocked.value, isTrue);
expect(
const SyncLockedException(E2eLockReason.wrongKey).toString(),
contains('wrongKey'),
);
});
test('a wake-alarm transport failure skips only the weigh-ins', () async {
await LogStorageService.instance.logMeal('oatmeal', manualNutrition);
final fake = FakeGitHub();
final store = _FailingPath(fake.buildClient(), 'wake-alarm-sync/e2e');
await runSync(store);
expect(fake.putsByPath, contains('$_new/phone/food_log.json'));
expect(await openWakeAlarmStore(store), isNull);
});
}
/// Delegates to [inner] but fails every read of [path].
class _FailingPath implements RemoteStore {
_FailingPath(this.inner, this.path);
final RemoteStore inner;
final String path;
@override
Future<String?> getFileText(String p) =>
p == path ? Future.error(Exception('down')) : inner.getFileText(p);
@override
Future<List<String>> listDirectory(String p) => inner.listDirectory(p);
@override
Future<void> putFileText(String p, String text, {required String message}) =>
inner.putFileText(p, text, message: message);
@override
Future<void> deleteFile(String p, {String message = ''}) =>
inner.deleteFile(p, message: message);
@override
Future<bool> canAccessRemote() => inner.canAccessRemote();
@override
void close() => inner.close();
}

View File

@ -14,20 +14,20 @@ import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:flutter_test/flutter_test.dart';
import '../fake_secure_storage.dart';
import 'sync_service_test_support.dart';
void main() {
late Directory tempDir;
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
installFakeSecureStorage();
tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
});
tearDown(() async {
@ -39,24 +39,30 @@ void main() {
});
group('budget sync', () {
test('pushes the local budget when no other devices have synced', () async {
await AppSettingsService.instance.saveDailyKcalGoal(2000);
final fake = FakeGitHub();
await runSync(fake.buildClient());
test(
'pushes the local budget when no other devices have synced',
() async {
await AppSettingsService.instance.saveDailyKcalGoal(2000);
final fake = FakeGitHub();
await runSync(fake.buildClient());
expect(
fake.putsByPath.containsKey(
'diet-guard-sync/devices-e2e/phone/budget.json',
),
isTrue,
);
});
expect(
fake.putsByPath.containsKey(
'diet-guard-sync/devices/phone/budget.json',
),
isTrue,
);
},
);
test('remote-only budget is adopted locally', () async {
final remoteJson = remoteBudgetJson(kcal: 1800, t: '2026-01-01T09:00:00');
final remoteJson = remoteBudgetJson(
kcal: 1800,
t: '2026-01-01T09:00:00',
);
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: {'diet-guard-sync/devices-e2e/pc/budget.json': remoteJson},
files: {'diet-guard-sync/devices/pc/budget.json': remoteJson},
);
await runSync(fake.buildClient());
expect(AppSettingsService.dailyKcalGoal, 1800);
@ -64,10 +70,13 @@ void main() {
test('a local edit later than a remote edit wins', () async {
await AppSettingsService.instance.saveDailyKcalGoal(1500); // now
final remoteJson = remoteBudgetJson(kcal: 1800, t: '2020-01-01T09:00:00');
final remoteJson = remoteBudgetJson(
kcal: 1800,
t: '2020-01-01T09:00:00',
);
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: {'diet-guard-sync/devices-e2e/pc/budget.json': remoteJson},
files: {'diet-guard-sync/devices/pc/budget.json': remoteJson},
);
await runSync(fake.buildClient());
expect(AppSettingsService.dailyKcalGoal, 1500);
@ -75,10 +84,13 @@ void main() {
test('a remote edit later than a local edit wins', () async {
await AppSettingsService.instance.saveDailyKcalGoal(1500); // now
final remoteJson = remoteBudgetJson(kcal: 1800, t: '2999-01-01T09:00:00');
final remoteJson = remoteBudgetJson(
kcal: 1800,
t: '2999-01-01T09:00:00',
);
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: {'diet-guard-sync/devices-e2e/pc/budget.json': remoteJson},
files: {'diet-guard-sync/devices/pc/budget.json': remoteJson},
);
await runSync(fake.buildClient());
expect(AppSettingsService.dailyKcalGoal, 1800);
@ -89,7 +101,7 @@ void main() {
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: const {
'diet-guard-sync/devices-e2e/pc/budget.json': '{not valid json',
'diet-guard-sync/devices/pc/budget.json': '{not valid json',
},
);
await runSync(fake.buildClient());
@ -102,9 +114,11 @@ void main() {
final fake = FakeGitHub();
await runSync(fake.buildClient());
final pushedText = (await fake.opened(
'diet-guard-sync/devices-e2e/phone/budget.json',
))!;
final pushed =
fake.putsByPath['diet-guard-sync/devices/phone/budget.json']!;
final pushedText = utf8.decode(
base64.decode(pushed['content'] as String),
);
expect(jsonDecode(pushedText), isEmpty);
expect(AppSettingsService.dailyKcalGoal, 2200);
},

View File

@ -16,20 +16,20 @@ import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:flutter_test/flutter_test.dart';
import '../fake_secure_storage.dart';
import 'sync_service_test_support.dart';
void main() {
late Directory tempDir;
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
installFakeSecureStorage();
tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
});
tearDown(() async {
@ -49,32 +49,24 @@ void main() {
// syncLog always pushes, even an empty merged result: food_log.json,
// budget.json, food_bank.json, food_bank_manual.json -- plus this
// device's revision, published after the log so a peer can never cache
// "seen rev X" against a log it never received. Then export_keys.json,
// and the two encryption headers (diet_guard's and wake-alarm's).
expect(
fake.putsByPath.keys.where((p) => !p.endsWith('/e2e')),
hasLength(6),
);
expect(fake.puts, hasLength(8));
// "seen rev X" against a log it never received.
expect(fake.puts, hasLength(5));
});
test("skips its own device id ('phone') when listing", () async {
final fake = FakeGitHub(
deviceDirs: const ['pc', 'phone'],
files: const {
'diet-guard-sync/devices-e2e/pc/food_log.json': '{}',
'diet-guard-sync/devices-e2e/pc/budget.json': '{}',
'diet-guard-sync/devices/pc/food_log.json': '{}',
'diet-guard-sync/devices/pc/budget.json': '{}',
},
);
await runSync(fake.buildClient());
// Both the food-log and budget pulls skip "phone" (this device) and
// only ever read "pc"'s files.
expect(
fake.fileGets.where((p) => p.startsWith('diet-guard-sync/devices-e2e/')),
[
'diet-guard-sync/devices-e2e/pc/food_log.json',
'diet-guard-sync/devices-e2e/pc/budget.json',
],
);
expect(fake.fileGets, [
'diet-guard-sync/devices/pc/food_log.json',
'diet-guard-sync/devices/pc/budget.json',
]);
});
test('skips a device with no pushed file yet', () async {
final fake = FakeGitHub(deviceDirs: const ['pc']);
@ -84,7 +76,7 @@ void main() {
test('ignores a device whose pushed file is not a JSON object', () async {
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: const {'diet-guard-sync/devices-e2e/pc/food_log.json': '[]'},
files: const {'diet-guard-sync/devices/pc/food_log.json': '[]'},
);
final merged = await runSync(fake.buildClient());
expect(merged, isEmpty);
@ -93,7 +85,7 @@ void main() {
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: const {
'diet-guard-sync/devices-e2e/pc/food_log.json': '{not valid json',
'diet-guard-sync/devices/pc/food_log.json': '{not valid json',
},
);
final merged = await runSync(fake.buildClient());
@ -119,7 +111,7 @@ void main() {
});
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: {'diet-guard-sync/devices-e2e/pc/food_log.json': remoteLog},
files: {'diet-guard-sync/devices/pc/food_log.json': remoteLog},
);
final merged = await runSync(fake.buildClient());
final descs = merged.values.expand((e) => e).map((e) => e.desc).toSet();
@ -153,7 +145,7 @@ void main() {
});
final fake = FakeGitHub(
deviceDirs: const ['pc'],
files: {'diet-guard-sync/devices-e2e/pc/food_log.json': remoteLog},
files: {'diet-guard-sync/devices/pc/food_log.json': remoteLog},
);
final merged = await runSync(fake.buildClient());
final descs = merged.values.expand((e) => e).map((e) => e.desc).toSet();
@ -169,13 +161,16 @@ void main() {
expect(bank.containsKey('oatmeal'), isTrue);
});
test('pushes a payload without hmac', () async {
await LogStorageService.instance.logMeal('oatmeal', manualNutrition);
await LogStorageService.instance.logMeal(
'oatmeal',
manualNutrition,
);
final fake = FakeGitHub();
await runSync(fake.buildClient());
final pushedText = (await fake.opened(
'diet-guard-sync/devices-e2e/phone/food_log.json',
))!;
final pushed =
fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!;
final pushedText = utf8.decode(base64.decode(pushed['content'] as String));
expect(pushedText, isNot(contains('hmac')));
});
test('pushes in the new Record-based wire format', () async {
@ -183,9 +178,9 @@ void main() {
final fake = FakeGitHub();
await runSync(fake.buildClient());
final pushedText = (await fake.opened(
'diet-guard-sync/devices-e2e/phone/food_log.json',
))!;
final pushed =
fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!;
final pushedText = utf8.decode(base64.decode(pushed['content'] as String));
final decoded = jsonDecode(pushedText) as Map<String, dynamic>;
final record = decoded.values.single as Map<String, dynamic>;
expect(record, containsPair('id', isA<String>()));
@ -193,11 +188,11 @@ void main() {
});
test("reuses this device's existing sha when it has pushed before", () async {
final fake = FakeGitHub(
files: const {'diet-guard-sync/devices-e2e/phone/food_log.json': '{}'},
files: const {'diet-guard-sync/devices/phone/food_log.json': '{}'},
);
await runSync(fake.buildClient());
expect(
fake.putsByPath['diet-guard-sync/devices-e2e/phone/food_log.json']!['sha'],
fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!['sha'],
'f-phone',
);
});

View File

@ -65,41 +65,6 @@ class FakeGitHub {
/// sync tick does both.
final Map<String, Map<String, dynamic>> putsByPath = {};
/// A directory listing for [path], or null when it is not a directory.
///
/// [deviceDirs] populate both the encrypted prefix and the old plaintext
/// one; any other directory is derived from [files].
List<Map<String, String>>? _list(String path) {
final names = <String>{
if (path == 'diet-guard-sync/devices-e2e' ||
path == 'diet-guard-sync/devices')
...deviceDirs,
for (final key in files.keys)
if (key.startsWith('$path/'))
key.substring(path.length + 1).split('/')[0],
};
if (names.isEmpty) return null;
return [
for (final name in names)
{
'type': files.containsKey('$path/$name') ? 'file' : 'dir',
'name': name,
'path': '$path/$name',
'sha': 'd-$name',
},
];
}
/// The opened (decrypted) text of what was pushed to [path].
Future<String?> opened(String path) async {
final state = await openEncryptedStore(
store: buildClient(),
oldPrefix: 'diet-guard-sync/devices',
readPassphrase: () async => 'test-passphrase',
);
return await (state as E2eReady).store.getFileText(path);
}
GitHubClient buildClient() => GitHubClient(
owner: 'o',
repo: 'r',
@ -119,12 +84,22 @@ class FakeGitHub {
final body = jsonDecode(req.body) as Map<String, dynamic>;
puts.add(body);
putsByPath[path] = body;
// Stored, so the encryption header (and anything sealed) reads back.
files[path] = utf8.decode(base64.decode(body['content'] as String));
return http.Response('{}', 200);
}
final listing = _list(path);
if (listing != null) return http.Response(jsonEncode(listing), 200);
if (path == 'diet-guard-sync/devices') {
return http.Response(
jsonEncode([
for (final d in deviceDirs)
{
'type': 'dir',
'name': d,
'path': 'diet-guard-sync/devices/$d',
'sha': 'd-$d',
},
]),
200,
);
}
if (!files.containsKey(path)) return http.Response('', 404);
fileGets.add(path);
final content = base64.encode(utf8.encode(files[path]!));
@ -135,9 +110,12 @@ class FakeGitHub {
final sha =
segments.length == 4 &&
segments[0] == 'diet-guard-sync' &&
segments[1] == 'devices-e2e'
segments[1] == 'devices'
? 'f-${segments[2]}'
: null;
return http.Response(jsonEncode({'content': content, 'sha': ?sha}), 200);
return http.Response(
jsonEncode({'content': content, 'sha': ?sha}),
200,
);
}
}

View File

@ -1,12 +1,10 @@
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/widgets/meal_schedule_editor.dart';
import 'package:diet_guard_app/widgets/meal_time_dropdown.dart';
import 'package:diet_guard_app/widgets/pickers/time_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import '../pickers/picker_test_support.dart';
/// Holds the schedule the way `_SettingsMealSchedule` does: every edit is
/// normalised and fed back, so the preview reflects what would be saved.
@ -68,13 +66,13 @@ void main() {
await tester.pumpAndSettle();
}
/// Taps [label]'s clock button, scrolls the time picker's wheels to
/// [hh]:[mm] and confirms.
/// Taps [label]'s clock button and types [hh]:[mm] in the time picker's
/// keyboard mode.
Future<void> pickExact(
WidgetTester tester,
String label,
int hh,
int mm,
String hh,
String mm,
) async {
await tester.tap(
find.descendant(
@ -85,9 +83,17 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(find.byType(TimeDialog), findsOneWidget);
await setWheels(tester, hh, mm);
await confirm(tester);
expect(find.byType(TimePickerDialog), findsOneWidget);
await tester.tap(find.byIcon(Icons.keyboard_outlined));
await tester.pumpAndSettle();
final fields = find.descendant(
of: find.byType(TimePickerDialog),
matching: find.byType(TextField),
);
await tester.enterText(fields.at(0), hh);
await tester.enterText(fields.at(1), mm);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
testWidgets('an off-grid value from another device renders', (tester) async {
@ -128,7 +134,7 @@ void main() {
) async {
await pump(tester, _fiveMeals);
await pickExact(tester, 'First meal', 7, 23);
await pickExact(tester, 'First meal', '07', '23');
expect(edits.last.firstMinute, 443);
expect(edits.last.lastMinute, 1200);
@ -153,15 +159,12 @@ void main() {
expect(find.text('Custom…'), findsNothing);
});
testWidgets('a last meal before the first cannot be picked', (tester) async {
testWidgets('an exact last meal before the first is clamped', (tester) async {
await pump(tester, _fiveMeals);
// The last meal's range starts 08:15, one grid step after the first.
// 06 is dimmed, so the hour wheel springs to 08, the nearest allowed
// hour, and :00 to :15: the picker never offers an illegal time, so
// nothing is clamped after the fact.
await pickExact(tester, 'Last meal', 6, 0);
await pickExact(tester, 'Last meal', '06', '00');
// One grid step after 08:00: the shortest legal window, not a rejection.
expect(edits.last.lastMinute, 495);
});

View File

@ -1,11 +0,0 @@
# Dependencies held below latest stable in this repo, and why. `transitive:`
# entries are predicates: they clear themselves when the blocker moves, so
# they carry no `expires`; everything else is discretionary and expires.
exceptions:
- ecosystem: toolchain
package: flutter
pinned: "3.47.9"
reason: local toolchain /opt/flutter is 3.47.9; fleet-wide Flutter 3.50 bump is separate work
blocked_by: local-toolchain
expires: "2026-11-26"

View File

@ -2,9 +2,8 @@
The wake-alarm phone app publishes ``latest_weight_kg`` and a short
``morning_sessions`` list on its own ``alarm.json`` in the shared Firebase
project, end-to-end encrypted under ``wake-alarm-sync/devices-e2e``. Reading
it over diet_guard's *own* backend -- paths are absolute there -- means no
import of ``wake_alarm`` and no second sign-in.
project. Reading it through diet_guard's *own* sync client -- paths are
absolute there -- means no import of ``wake_alarm`` and no second sign-in.
Stamping rules (``docs/DOCS-body.md``): ``latest_weight_kg`` carries its
field's Hlc time, so a re-typed weigh-in wins; ``morning_sessions`` entries
@ -22,21 +21,16 @@ import json
import logging
from typing import TYPE_CHECKING
from crdt_sync import E2eLockedError, Hlc, MirrorSyncClient, RemoteSyncError
from crdt_sync import Hlc, MirrorSyncClient, RemoteSyncError
from diet_guard._body_store import MAX_KG, MIN_KG, WeightCandidate, stamp_ms
from diet_guard._sync_e2e import (
LEGACY_WAKE_ALARM_DIR,
E2eStore,
open_e2e,
read_sealed,
)
if TYPE_CHECKING:
from crdt_sync import RemoteStore
_logger = logging.getLogger(__name__)
WAKE_ALARM_DEVICES_DIR = "wake-alarm-sync/devices"
_LATEST_FIELD = "latest_weight_kg"
_SESSIONS_FIELD = "morning_sessions"
# A crdt_sync field on the wire is ``[value, hlc]``.
@ -111,25 +105,19 @@ def weight_candidates(published: list[tuple[str, str]]) -> list[WeightCandidate]
def fetch_alarm_logs(client: RemoteStore) -> list[tuple[str, str]]:
"""Every wake-alarm device's decrypted ``alarm.json``; ``[]`` on any failure.
"""Every wake-alarm device's raw ``alarm.json``; ``[]`` on any failure.
Firebase-only (the primary of a mirror client): wake-alarm never
published weigh-ins to the GitHub mirror. wake-alarm's records are sealed
under their own header, so its store is opened over ``client``'s raw
backend; a locked or unreadable namespace skips the ingest, never the
diet sync around it.
published weigh-ins to the GitHub mirror.
"""
raw = client.raw if isinstance(client, E2eStore) else client
primary = raw.primary if isinstance(raw, MirrorSyncClient) else raw
store = client.primary if isinstance(client, MirrorSyncClient) else client
try:
store = open_e2e(primary, LEGACY_WAKE_ALARM_DIR)
prefix = store.layout.prefix
published = []
for device in store.list_directory(prefix):
text = read_sealed(store, f"{prefix}/{device}/alarm.json")
for device in store.list_directory(WAKE_ALARM_DEVICES_DIR):
text = store.get_file_text(f"{WAKE_ALARM_DEVICES_DIR}/{device}/alarm.json")
if text is not None:
published.append((device, text))
except (E2eLockedError, RemoteSyncError) as exc:
except RemoteSyncError as exc:
_logger.info("wake-alarm weigh-ins unreadable (%s); skipped", exc)
return []
return published

View File

@ -27,9 +27,6 @@ FOOD_LOG_FILE: Path = DATA_DIR / "food_log.json"
# peer's records are already merged into the local log, so state that outlived
# its log would skip peers whose data had been lost.
SYNC_STATE_FILE: Path = DATA_DIR / "sync_state.json"
# End-to-end encryption state kept between gate processes: the derived key
# (0600) and which old plaintext revisions were already copied under E2E.
E2E_STATE_DIR: Path = DATA_DIR / "e2e"
# The user's personal "food bank": every food they have logged before, with its
# full macros, keyed by name. This is the ONLY corpus the gate's autocomplete
# searches -- Open Food Facts is used to *fill* a new food's macros, never to

View File

@ -100,16 +100,11 @@ def _clock_window(day: str, item: dict[str, object]) -> tuple[str | None, str |
def manual_session(
day: str, item: dict[str, object], minutes: float, label: str
) -> Session:
"""The :class:`Session` one manual workout stands for.
Source is "manual" so the phone's activity sheet can paint the human-typed
label (``source == "manual"`` in Dart). Verified screen-locker workouts keep
source "screen-locker".
"""
"""The :class:`Session` one manual workout stands for."""
text = _text(item)
kind = _kind(item, text)
km = _typed_km(item)
if km is None and kind in {"run", "walk"}:
km = _text_km(text)
start, end = _clock_window(day, item)
return Session(day, kind, minutes, km, label, "manual", start, end)
return Session(day, kind, minutes, km, label, "screen-locker", start, end)

View File

@ -46,7 +46,6 @@ from diet_guard._state_sync import (
from diet_guard._sync_banks import _sync_budget, _sync_food_bank, _sync_manual_bank
from diet_guard._sync_body import sync_body
from diet_guard._sync_client import _client_for_run
from diet_guard._sync_e2e import read_sealed
from diet_guard._sync_errors import SyncError
from diet_guard._sync_kuchnia import _sync_kuchnia_credential
from diet_guard._sync_paths import _DEVICES_DIR, _REVS_DIR, _device_log_path
@ -120,7 +119,7 @@ def _pull_remote_logs(
# revision forward so it stays skipped next tick.
seen_revs[device_id] = remote_rev
continue
text = read_sealed(client, _device_log_path(device_id))
text = client.get_file_text(_device_log_path(device_id))
if text is None:
continue
try:

View File

@ -37,7 +37,6 @@ from diet_guard._meal_schedule_store import (
from diet_guard._meal_schedule_store import (
write_raw_history as write_raw_schedule,
)
from diet_guard._sync_e2e import E2eStore, read_sealed
from diet_guard._sync_paths import (
_device_budget_path,
_device_food_bank_path,
@ -92,7 +91,7 @@ def _sync_food_bank(client: GitHubSyncClient, device_ids: Sequence[str]) -> None
for device_id in device_ids:
if identity.is_own(device_id):
continue
text = read_sealed(client, _device_food_bank_path(device_id))
text = client.get_file_text(_device_food_bank_path(device_id))
if text is None:
continue
try:
@ -136,15 +135,10 @@ def _primary_only_text(client: GitHubSyncClient, path: str) -> str | None:
2 empty and **zero** non-empty. No curated food exists on the mirror alone,
so nothing is lost. A client with no ``primary`` (the GitHub-only case) is
read directly, which is the same backend either way.
Under E2E the client is an :class:`E2eStore`; its ``primary_only`` view
keeps the same narrowing with decryption in front of it.
"""
if isinstance(client, E2eStore):
return read_sealed(client.primary_only(), path)
if isinstance(client, MirrorSyncClient):
return read_sealed(client.primary, path)
return read_sealed(client, path)
return client.primary.get_file_text(path)
return client.get_file_text(path)
def _sync_manual_bank(client: GitHubSyncClient, device_ids: Sequence[str]) -> None:
@ -202,7 +196,7 @@ def _sync_budget(client: GitHubSyncClient, device_ids: Sequence[str]) -> None:
for device_id in device_ids:
if identity.is_own(device_id):
continue
text = read_sealed(client, _device_budget_path(device_id))
text = client.get_file_text(_device_budget_path(device_id))
if text is None:
continue
try:

View File

@ -22,7 +22,6 @@ from typing import TYPE_CHECKING
from crdt_sync import (
CONFIG_FILE,
ConfigError,
E2eLockedError,
FirebaseAuthError,
GitHubSyncClient,
RemoteSyncError,
@ -37,7 +36,6 @@ from diet_guard._constants import (
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from diet_guard._sync_e2e import E2eStore, open_e2e
from diet_guard._sync_errors import SyncError
if TYPE_CHECKING:
@ -72,26 +70,7 @@ def _remote_client(github: GitHubSyncClient) -> RemoteStore:
return github
def _encrypted(raw: RemoteStore) -> E2eStore:
"""Wrap ``raw`` in end-to-end encryption, failing closed.
A locked store becomes a :class:`SyncError`, which every caller already
reports and survives -- the gate keeps its own lock decision -- and
nothing is ever pulled or pushed in plaintext.
"""
try:
return open_e2e(raw)
except E2eLockedError as exc:
message = f"sync locked (end-to-end encryption): {exc}"
raise SyncError(message) from exc
def _client_for_run() -> E2eStore:
"""Return the end-to-end encrypted store for one sync run."""
return _encrypted(_raw_client_for_run())
def _raw_client_for_run() -> RemoteStore:
def _client_for_run() -> RemoteStore:
"""Return the backend for one sync run, requiring only *one* to be set up.
The PAT is no longer mandatory. Firebase is the primary backend now, and
@ -176,14 +155,13 @@ def _apply_timeout(client: RemoteStore, seconds: float) -> None:
vars(target)["_timeout_seconds"] = seconds
def _client_for_interactive_run() -> E2eStore:
def _client_for_interactive_run() -> RemoteStore:
"""Return a sync client budgeted for a path the user is waiting on.
Same backends as :func:`_client_for_run`, but with
:data:`~diet_guard._constants.INTERACTIVE_TIMEOUT_SECONDS` in place of the
background tick's much longer budget.
"""
raw = _raw_client_for_run()
# Tightened before the E2E open, which already makes requests.
_apply_timeout(raw, INTERACTIVE_TIMEOUT_SECONDS)
return _encrypted(raw)
client = _client_for_run()
_apply_timeout(client, INTERACTIVE_TIMEOUT_SECONDS)
return client

View File

@ -1,123 +0,0 @@
"""End-to-end encryption of every payload this app syncs.
Every blob under ``diet-guard-sync/devices-e2e`` is sealed with a key derived
(Argon2id) from the shared crdt-sync passphrase in
``~/.config/crdt-sync/password``; Firebase and the GitHub mirror only ever see
record ids, tombstones and clocks. The wake-alarm weigh-ins this app reads
live on their own prefix (``wake-alarm-sync/devices-e2e``) with their own
header, hence a second store over the same raw backend.
Fail closed: no passphrase, a wrong one or an unreadable header raises
:class:`~crdt_sync.E2eLockedError` and nothing is pulled or pushed -- never a
plaintext fallback.
The old plaintext prefix is copied over (and never deleted) while a device
not yet migrated may still write it; see :func:`open_e2e` for when.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from crdt_sync import (
PASSWORD_FILE,
E2eDecryptError,
E2eLayout,
E2eLockedError,
EncryptingStore,
MirrorSyncClient,
open_encrypted_client,
)
from diet_guard._constants import E2E_STATE_DIR
if TYPE_CHECKING:
from crdt_sync import Argon2Params, E2eKey, RemoteStore
_logger = logging.getLogger(__name__)
#: The plaintext prefix every pre-E2E build wrote; read only by the copy.
LEGACY_DEVICES_DIR = "diet-guard-sync/devices"
#: Where the wake-alarm app published before E2E.
LEGACY_WAKE_ALARM_DIR = "wake-alarm-sync/devices"
DIET_LAYOUT = E2eLayout.of(LEGACY_DEVICES_DIR)
WAKE_ALARM_LAYOUT = E2eLayout.of(LEGACY_WAKE_ALARM_DIR)
#: Argon2id cost for a header this device creates; None is the library's
#: default. Only tests lower it.
ARGON2_PARAMS: Argon2Params | None = None
class E2eStore(EncryptingStore):
"""An :class:`EncryptingStore` that remembers the backend it wraps.
The raw backend is what a second prefix (wake-alarm) is opened over, and
its Firebase half is what the primary-only reads go to.
"""
def __init__(self, raw: RemoteStore, key: E2eKey, layout: E2eLayout) -> None:
"""Wrap ``raw`` for ``layout``, sealing with ``key``."""
super().__init__(raw, key, layout)
self.raw = raw
self.layout = layout
def primary_only(self) -> EncryptingStore:
"""The same encryption over the Firebase half alone (no mirror)."""
raw = self.raw
inner = raw.primary if isinstance(raw, MirrorSyncClient) else raw
return EncryptingStore(inner, self.key, self.layout)
def _read_passphrase() -> str:
"""The shared crdt-sync passphrase, or "" when this machine has none."""
try:
return PASSWORD_FILE.read_text().strip()
except OSError:
return ""
def open_e2e(raw: RemoteStore, old_prefix: str = LEGACY_DEVICES_DIR) -> E2eStore:
"""Unlock ``old_prefix``'s encrypted twin over ``raw``.
diet_guard's own prefix keeps its derived key and copy memo in
:data:`E2E_STATE_DIR`, so a fresh gate process neither re-runs Argon2id
nor re-reads every old blob. The wake-alarm prefix does not: its phone
publishes no revisions, so a persisted memo would never see a phone not
yet migrated write a new alarm, and the copy reruns once per process.
Raises:
E2eLockedError: No passphrase, a wrong one, an unreadable header, or
a copy that met a blob it cannot open.
"""
# An empty passphrase still opens with diet_guard's cached key; without
# one the library raises E2eLockedError.
passphrase = _read_passphrase()
state_dir = E2E_STATE_DIR if old_prefix == LEGACY_DEVICES_DIR else None
try:
store, layout = open_encrypted_client(
raw,
old_prefix,
passphrase=passphrase,
params=ARGON2_PARAMS,
state_dir=state_dir,
)
except E2eDecryptError as exc:
raise E2eLockedError(str(exc)) from exc
return E2eStore(raw, store.key, layout)
def read_sealed(store: RemoteStore, path: str) -> str | None:
"""``store.get_file_text``, with an undecryptable blob treated as absent.
A peer sealed under another key, tampered with, or written by a newer
format is skipped (and so retried next tick), never fatal: one bad peer
must not stall the merge of every other one.
"""
try:
return store.get_file_text(path)
except E2eDecryptError as exc:
_logger.warning("e2e: skipping %s: %s", path, exc)
return None

View File

@ -29,7 +29,6 @@ from diet_guard._kuchnia_credential_store import (
write_synced_credential,
)
from diet_guard._kuchnia_errors import KuchniaError
from diet_guard._sync_e2e import read_sealed
from diet_guard._sync_paths import _device_kuchnia_path
from diet_guard.sync_merge import (
credential_to_log,
@ -117,7 +116,7 @@ def _sync_kuchnia_credential(
for device_id in device_ids:
if identity.is_own(device_id):
continue
text = read_sealed(client, _device_kuchnia_path(device_id))
text = client.get_file_text(_device_kuchnia_path(device_id))
if text is None:
continue
try:

View File

@ -12,16 +12,11 @@ constant; see :mod:`diet_guard._device`.
from __future__ import annotations
from diet_guard._sync_e2e import DIET_LAYOUT
#: The end-to-end encrypted prefix (``diet-guard-sync/devices-e2e``); the old
#: plaintext ``diet-guard-sync/devices`` is only ever read, by the E2E copy.
_DEVICES_DIR = DIET_LAYOUT.prefix
_DEVICES_DIR = "diet-guard-sync/devices"
#: Where per-device revision markers live, used to skip re-reading an
#: unchanged remote log. Keyed (HMAC) by the encrypting store, and kept out
#: of the old ``diet-guard-sync/revs`` the copy fingerprints.
_REVS_DIR = DIET_LAYOUT.revs_path
#: unchanged remote log.
_REVS_DIR = "diet-guard-sync/revs"
def _device_log_path(device_id: str) -> str:

View File

@ -46,7 +46,6 @@ from diet_guard._device import device_identity
from diet_guard._state_sync import read_raw_log, resign_entry, write_raw_log
from diet_guard._sync import _remote_revisions
from diet_guard._sync_client import _client_for_interactive_run
from diet_guard._sync_e2e import read_sealed
from diet_guard._sync_errors import SyncError
from diet_guard._sync_paths import _DEVICES_DIR, _device_log_path
from diet_guard.sync_merge import daylog_to_log, log_to_daylog, parse_remote_log
@ -117,7 +116,7 @@ def _merge_peer_logs(
"""Merge each peer's pushed log into ``merged``; report whether any landed."""
changed = False
for device_id in peers:
text = read_sealed(client, _device_log_path(device_id))
text = client.get_file_text(_device_log_path(device_id))
if text is None:
continue
try:

View File

@ -10,7 +10,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from crdt_sync import Argon2Params
import freedays
import sickdays
@ -19,19 +18,6 @@ if TYPE_CHECKING:
from unittest.mock import _patch
#: The passphrase every test device shares, unless a test says otherwise.
TEST_PHRASE = "test-passphrase"
#: Argon2id at a cost a test suite can afford thousands of times.
TEST_ARGON2 = Argon2Params(memory_kib=8, iterations=1, parallelism=1)
def _passphrase_file(tmp_path: Path) -> Path:
path = tmp_path / "crdt-sync-password"
path.write_text(TEST_PHRASE)
return path
def state_redirects(tmp_path: Path) -> list[_patch[object]]:
"""Every patch that points diet_guard state and network at ``tmp_path``."""
return [
@ -85,13 +71,6 @@ def state_redirects(tmp_path: Path) -> list[_patch[object]]:
"diet_guard._sync.SYNC_STATE_FILE",
tmp_path / "sync_state.json",
),
# End-to-end encryption: never the real ~/.config/crdt-sync/password,
# a cheap Argon2id cost, and the key/copy memo beside the other state.
patch("diet_guard._sync_e2e.PASSWORD_FILE", _passphrase_file(tmp_path)),
patch("diet_guard._sync_e2e.ARGON2_PARAMS", TEST_ARGON2),
patch("diet_guard._sync_e2e.E2E_STATE_DIR", tmp_path / "e2e"),
# The library's per-process copy memo must not leak between tests.
patch.dict("crdt_sync._e2e_store._copied", clear=True),
# Without this a test that syncs mints a uuid into the REAL
# ~/.local/share/diet_guard/.device_id, and this machine's live sync
# identity is decided by whichever test happened to run first.

View File

@ -57,68 +57,3 @@ def _mock_client(
# no revision map means every peer is fetched, exactly as before.
del client.get_string_map
return client
#: Paths only the end-to-end encryption touches: the two headers, and the
#: old plaintext prefixes the one-time copy reads.
_E2E_BOOKKEEPING = ("diet-guard-sync/e2e", "wake-alarm-sync/e2e")
_OLD_PREFIXES = ("diet-guard-sync/devices/", "wake-alarm-sync/devices/")
def _data_paths(method: MagicMock) -> list[str]:
"""The paths ``method`` was called with, minus E2E header/copy traffic."""
return [
call.args[0]
for call in method.call_args_list
if call.args[0] not in _E2E_BOOKKEEPING
and not call.args[0].startswith(_OLD_PREFIXES)
and call.args[0] not in {"diet-guard-sync/devices", "wake-alarm-sync/devices"}
]
class DictStore:
"""In-memory ``RemoteStore``: paths are keys, directories are derived."""
def __init__(self, files: dict[str, str] | None = None) -> None:
"""Start with ``files`` (path -> text)."""
self.files = dict(files or {})
self.puts: list[str] = []
def list_directory(self, path: str) -> list[str]:
"""Immediate children of ``path``."""
prefix = f"{path}/"
return sorted(
{
k[len(prefix) :].split("/", 1)[0]
for k in self.files
if k.startswith(prefix)
}
)
def get_file_text(self, path: str) -> str | None:
"""The stored text, or None."""
return self.files.get(path)
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Store ``text`` and record the write."""
del message
self.files[path] = text
self.puts.append(path)
def get_string_map(self, path: str) -> dict[str, str]:
"""Leaf children of ``path`` with their text."""
prefix = f"{path}/"
return {
k[len(prefix) :]: v
for k, v in self.files.items()
if k.startswith(prefix) and "/" not in k[len(prefix) :]
}
def delete_file(self, path: str, *, message: str = "") -> None:
"""Forget ``path``."""
del message
self.files.pop(path, None)
def can_access_remote(self) -> bool:
"""Always reachable."""
return True

View File

@ -18,7 +18,6 @@ from diet_guard.sync_merge._body import (
log_to_body,
parse_remote_body,
)
from diet_guard.tests._sync_fakes import DictStore, _data_paths
# 2026-09-27 ~21:00 UTC: after that morning's weigh-in, as a real publish is.
_HLC = Hlc(wall_time_ms=1_790_535_000_000, counter=0, node_id="phone").to_str()
@ -87,29 +86,24 @@ class TestWeightCandidates:
assert [c.kg for c in cands] == [71.0]
class _DownStore(DictStore):
def list_directory(self, path: str) -> list[str]:
raise RemoteSyncError(path)
class TestFetchAlarmLogs:
def test_reads_every_device_and_skips_missing_files(self) -> None:
store = DictStore(
{
"wake-alarm-sync/devices-e2e/a/alarm.json": "{}",
"wake-alarm-sync/devices-e2e/b/other.json": "{}",
}
)
store = MagicMock()
store.list_directory.return_value = ["a", "b"]
store.get_file_text.side_effect = ["{}", None]
assert fetch_alarm_logs(store) == [("a", "{}")]
def test_mirror_reads_the_primary_only(self) -> None:
primary = DictStore({"wake-alarm-sync/devices-e2e/a/alarm.json": "{}"})
mirror = DictStore({"wake-alarm-sync/devices-e2e/m/alarm.json": "{}"})
assert fetch_alarm_logs(MirrorSyncClient(primary, mirror)) == [("a", "{}")]
assert mirror.puts == []
mirror = MagicMock(spec=MirrorSyncClient)
mirror.primary = MagicMock()
mirror.primary.list_directory.return_value = []
assert fetch_alarm_logs(mirror) == []
mirror.primary.list_directory.assert_called_once()
def test_a_remote_failure_reads_as_nothing(self) -> None:
assert fetch_alarm_logs(_DownStore()) == []
store = MagicMock()
store.list_directory.side_effect = RemoteSyncError("down")
assert fetch_alarm_logs(store) == []
class TestLogAdapter:
@ -187,18 +181,17 @@ class TestSyncBody:
"src": "manual",
"t": "2026-09-20T08:00:00+02:00",
}
path = "diet-guard-sync/devices-e2e/peer/body.json"
path = "diet-guard-sync/devices/peer/body.json"
client = self._client({path: encode_body_for_push(body_to_log(peer_doc))})
_sync_body.sync_body(client, ["peer", device_id()])
assert weights() == {"2026-09-20": 74.0}
pushed = client.put_file_text.call_args.args
assert pushed[0] == f"diet-guard-sync/devices-e2e/{device_id()}/body.json"
assert pushed[0] == f"diet-guard-sync/devices/{device_id()}/body.json"
def test_skips_missing_and_unparsable_peers(self) -> None:
client = self._client({"diet-guard-sync/devices-e2e/bad/body.json": "{oops"})
client = self._client({"diet-guard-sync/devices/bad/body.json": "{oops"})
_sync_body.sync_body(client, ["bad", "absent"])
# Only wake-alarm's encryption header, created on first use; no body.
assert _data_paths(client.put_file_text) == []
client.put_file_text.assert_not_called()
def test_ingests_phone_weigh_ins_first(self) -> None:
client = self._client({})

View File

@ -47,7 +47,3 @@ class TestManualParse:
assert start is not None
assert end is not None
assert logged_window("never", 60) == (None, None)
def test_manual_session_source_is_manual(self) -> None:
session = manual_session("2026-09-20", {}, 45.0, "walk to work")
assert session.source == "manual"

View File

@ -25,7 +25,7 @@ def _client(logs: dict[str, dict[str, object]]) -> MagicMock:
client = MagicMock()
client.list_directory.return_value = list(logs)
files = {
f"diet-guard-sync/devices-e2e/{dev}/food_log.json": json.dumps(recs)
f"diet-guard-sync/devices/{dev}/food_log.json": json.dumps(recs)
for dev, recs in logs.items()
}
client.get_file_text.side_effect = files.get
@ -124,7 +124,7 @@ class TestApply:
list(_prune_peers.apply_prune(client, ["dead"]))
deleted = [call.args[0] for call in client.delete_file.call_args_list]
assert "diet-guard-sync/revs-e2e/dead" in deleted
assert "diet-guard-sync/revs/dead" in deleted
def test_apply_deletes_all_four_paths(self) -> None:
client = MagicMock()

View File

@ -16,12 +16,7 @@ from diet_guard import _sync, _sync_client
from diet_guard._device import device_id
from diet_guard._foodbank_search import lookup_food
from diet_guard._state import load_log, log_meal
from diet_guard.tests._sync_fakes import (
_data_paths,
_mock_client,
_nutrition,
_write_token,
)
from diet_guard.tests._sync_fakes import _mock_client, _nutrition, _write_token
class TestReadToken:
@ -56,14 +51,14 @@ class TestRunSync:
merged = _sync.run_sync()
assert sum(len(entries) for entries in merged.values()) == 1
pushed = _data_paths(client.put_file_text)
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
# The log push, the derived bank rebuilt from that same log, and this
# device's revision -- published *after* the log, so a peer can never
# cache "seen rev X" against a log it never received.
assert pushed == [
f"diet-guard-sync/devices-e2e/{device_id()}/food_bank.json",
f"diet-guard-sync/devices-e2e/{device_id()}/food_log.json",
f"diet-guard-sync/revs-e2e/{device_id()}",
f"diet-guard-sync/devices/{device_id()}/food_bank.json",
f"diet-guard-sync/devices/{device_id()}/food_log.json",
f"diet-guard-sync/revs/{device_id()}",
]
log_call = next(
call
@ -71,18 +66,16 @@ class TestRunSync:
if call.args[0].endswith("food_log.json")
)
pushed_json = log_call.args[1]
# Sealed: one envelope record, id in clear, fields encrypted.
pushed = json.loads(pushed_json)
assert pushed["v"] == 1
((rid, record),) = pushed["r"].items()
assert rid
assert set(record) == {"d", "dh", "h", "p"}
(record,) = pushed.values()
assert "fields" in record
assert "id" in record
def test_skips_its_own_device_id_when_listing(self) -> None:
_write_token()
client = _mock_client(
devices=("pc", "phone"),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": "{}"},
files={"diet-guard-sync/devices/phone/food_log.json": "{}"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -90,16 +83,16 @@ class TestRunSync:
# body -- skips "pc" (this device) and only ever reads "phone"'s files.
# The wake-alarm weigh-ins are another app's devices, all read: the
# mock lists the same two ids for every directory.
requested_paths = _data_paths(client.get_file_text)
requested_paths = [call.args[0] for call in client.get_file_text.call_args_list]
assert requested_paths == [
"diet-guard-sync/devices-e2e/phone/food_log.json",
"diet-guard-sync/devices-e2e/phone/budget.json",
"diet-guard-sync/devices-e2e/phone/food_bank.json",
"diet-guard-sync/devices-e2e/phone/food_bank_manual.json",
"diet-guard-sync/devices-e2e/phone/kuchnia.json",
"wake-alarm-sync/devices-e2e/pc/alarm.json",
"wake-alarm-sync/devices-e2e/phone/alarm.json",
"diet-guard-sync/devices-e2e/phone/body.json",
"diet-guard-sync/devices/phone/food_log.json",
"diet-guard-sync/devices/phone/budget.json",
"diet-guard-sync/devices/phone/food_bank.json",
"diet-guard-sync/devices/phone/food_bank_manual.json",
"diet-guard-sync/devices/phone/kuchnia.json",
"wake-alarm-sync/devices/pc/alarm.json",
"wake-alarm-sync/devices/phone/alarm.json",
"diet-guard-sync/devices/phone/body.json",
]
def test_skips_a_device_with_no_pushed_file_yet(self) -> None:
@ -113,7 +106,7 @@ class TestRunSync:
_write_token()
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": "[]"},
files={"diet-guard-sync/devices/phone/food_log.json": "[]"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
@ -126,9 +119,7 @@ class TestRunSync:
_write_token()
client = _mock_client(
devices=("phone",),
files={
"diet-guard-sync/devices-e2e/phone/food_log.json": "{not valid json"
},
files={"diet-guard-sync/devices/phone/food_log.json": "{not valid json"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
@ -155,7 +146,7 @@ class TestRunSync:
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": remote_log_json},
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
@ -190,7 +181,7 @@ class TestRunSync:
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": remote_log_json},
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()

View File

@ -84,7 +84,7 @@ class TestClientForRun:
_sync_client, "firebase_client_for", lambda app: ("firebase", app)
)
assert _sync_client._raw_client_for_run() == ("firebase", "diet_guard")
assert _sync_client._client_for_run() == ("firebase", "diet_guard")
def test_unusable_firebase_config_becomes_a_sync_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View File

@ -35,7 +35,7 @@ class TestSyncFoodBank:
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices-e2e/{device_id()}/food_bank.json" in pushed
assert f"diet-guard-sync/devices/{device_id()}/food_bank.json" in pushed
def test_a_food_the_phone_logged_reaches_the_gate(self) -> None:
"""The payoff: a food eaten on the phone autocompletes on the PC.
@ -64,7 +64,7 @@ class TestSyncFoodBank:
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": remote_log_json},
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -92,7 +92,7 @@ class TestSyncFoodBank:
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_bank.json": remote},
files={"diet-guard-sync/devices/phone/food_bank.json": remote},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -120,7 +120,7 @@ class TestSyncFoodBank:
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_bank.json": remote},
files={"diet-guard-sync/devices/phone/food_bank.json": remote},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -131,7 +131,7 @@ class TestSyncFoodBank:
log_meal("apple", Nutrition(95, 0.5, 25, 0.3, 180, "manual"), 8)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_bank.json": "{not json"},
files={"diet-guard-sync/devices/phone/food_bank.json": "{not json"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -143,7 +143,7 @@ class TestSyncFoodBank:
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices-e2e/{device_id()}/food_bank.json" not in pushed
assert f"diet-guard-sync/devices/{device_id()}/food_bank.json" not in pushed
class TestSyncManualBank:
@ -164,9 +164,7 @@ class TestSyncManualBank:
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert (
f"diet-guard-sync/devices-e2e/{device_id()}/food_bank_manual.json" in pushed
)
assert f"diet-guard-sync/devices/{device_id()}/food_bank_manual.json" in pushed
def test_a_remote_curated_entry_is_adopted_locally(self) -> None:
"""The point of the whole feature: a food added on the phone is usable
@ -193,7 +191,7 @@ class TestSyncManualBank:
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_bank_manual.json": remote},
files={"diet-guard-sync/devices/phone/food_bank_manual.json": remote},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -222,7 +220,7 @@ class TestSyncManualBank:
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_bank_manual.json": remote},
files={"diet-guard-sync/devices/phone/food_bank_manual.json": remote},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -234,7 +232,7 @@ class TestSyncManualBank:
client = _mock_client(
devices=("phone",),
files={
"diet-guard-sync/devices-e2e/phone/food_bank_manual.json": "{not json",
"diet-guard-sync/devices/phone/food_bank_manual.json": "{not json",
},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):

View File

@ -36,7 +36,7 @@ class TestSyncBudget:
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list}
assert f"diet-guard-sync/devices-e2e/{device_id()}/budget.json" in pushed_paths
assert f"diet-guard-sync/devices/{device_id()}/budget.json" in pushed_paths
def test_refreshes_the_weight_from_the_log_after_the_merge(self) -> None:
"""The hook runs last, on every successful tick, into the sync log."""
@ -63,9 +63,7 @@ class TestSyncBudget:
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list}
assert (
f"diet-guard-sync/devices-e2e/{device_id()}/budget.json" not in pushed_paths
)
assert f"diet-guard-sync/devices/{device_id()}/budget.json" not in pushed_paths
def test_remote_only_budget_is_adopted_locally(self) -> None:
"""Only the phone has ever set a budget -- the PC adopts it."""
@ -73,7 +71,7 @@ class TestSyncBudget:
remote_json = _remote_budget_json(kcal=1800, t="2026-01-01T09:00:00")
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/budget.json": remote_json},
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -86,7 +84,7 @@ class TestSyncBudget:
remote_json = _remote_budget_json(kcal=1800, t="2020-01-01T09:00:00")
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/budget.json": remote_json},
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -104,7 +102,7 @@ class TestSyncBudget:
remote_json = _remote_budget_json(kcal=1800, t="2999-01-01T09:00:00")
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/budget.json": remote_json},
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -122,7 +120,7 @@ class TestSyncBudget:
remote_json = _remote_budget_json(kcal=1800, t="2999-01-01T09:00:00")
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/budget.json": remote_json},
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
@ -159,7 +157,7 @@ class TestSyncBudget:
write_budget(2000)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/budget.json": "{not valid json"},
files={"diet-guard-sync/devices/phone/budget.json": "{not valid json"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()

View File

@ -47,8 +47,8 @@ class TestPullSkipsOwnLegacyPath:
)
fetched = [call.args[0] for call in client.get_file_text.call_args_list]
assert "diet-guard-sync/devices-e2e/pc/food_log.json" not in fetched
assert "diet-guard-sync/devices-e2e/phone/food_log.json" in fetched
assert "diet-guard-sync/devices/pc/food_log.json" not in fetched
assert "diet-guard-sync/devices/phone/food_log.json" in fetched
def test_pulls_the_legacy_path_once_it_is_disowned(self) -> None:
"""Negative control: with no legacy id, devices/pc/ IS a peer.
@ -69,4 +69,4 @@ class TestPullSkipsOwnLegacyPath:
)
fetched = [call.args[0] for call in client.get_file_text.call_args_list]
assert "diet-guard-sync/devices-e2e/pc/food_log.json" in fetched
assert "diet-guard-sync/devices/pc/food_log.json" in fetched

View File

@ -1,309 +0,0 @@
"""End-to-end encryption of the sync payloads (``_sync_e2e``).
Runs the real sync tick over a dict-backed remote, so the header, the sealed
blobs and the keyed revisions are the bytes a real backend would hold.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
from crdt_sync import (
E2eCipher,
E2eDecryptError,
E2eLayout,
E2eLockedError,
MirrorSyncClient,
seal_log_text,
)
import pytest
from diet_guard import _sync, _sync_client, _sync_e2e
from diet_guard._body_ingest import fetch_alarm_logs
from diet_guard._device import device_id
from diet_guard._state import load_log, log_meal
from diet_guard._state_sync import read_raw_log, write_raw_log
from diet_guard._sync_banks import _primary_only_text
from diet_guard.sync_merge import daylog_to_log
from diet_guard.tests._sync_fakes import DictStore, _nutrition, _write_token
if TYPE_CHECKING:
from pathlib import Path
_PRIVATE_MEAL = "secret oatmeal"
_OLD = "diet-guard-sync/devices"
_NEW = "diet-guard-sync/devices-e2e"
def _run(store: DictStore) -> None:
_write_token()
with patch.object(_sync_client, "GitHubSyncClient", return_value=store):
_sync.run_sync()
def _peer_log(desc: str) -> str:
"""A plaintext food log a pre-E2E peer pushed, holding one meal."""
log_meal(desc, _nutrition(), slot=480)
log = daylog_to_log(read_raw_log())
write_raw_log({})
return json.dumps({rid: rec.to_dict() for rid, rec in log.items()})
def _all_meals() -> list[str]:
return [entry["desc"] for entries in load_log().values() for entry in entries]
class TestSealedPush:
def test_pushed_blobs_hold_no_plaintext(self) -> None:
log_meal(_PRIVATE_MEAL, _nutrition(), slot=480)
store = DictStore()
_run(store)
own = f"{_NEW}/{device_id()}"
log_blob = store.files[f"{own}/food_log.json"]
assert "secret" not in log_blob
assert '"fields"' not in log_blob
assert '"desc"' not in log_blob
# Record ids stay clear by design (kuhy's call: ids, tombstones and
# clocks are metadata). The food banks key records by food name, so
# only their *fields* are asserted sealed here.
for path, text in store.files.items():
assert "kcal" not in text, path
assert "protein" not in text, path
def test_nothing_is_written_to_the_old_plaintext_prefix(self) -> None:
log_meal(_PRIVATE_MEAL, _nutrition(), slot=480)
store = DictStore()
_run(store)
assert store.puts
for path in store.puts:
assert not path.startswith(f"{_OLD}/"), path
assert not path.startswith("diet-guard-sync/revs/"), path
assert f"diet-guard-sync/revs-e2e/{device_id()}" in store.files
assert "diet-guard-sync/e2e" in store.files
def test_another_device_with_the_passphrase_reads_it_back(self) -> None:
log_meal(_PRIVATE_MEAL, _nutrition(), slot=480)
store = DictStore()
_run(store)
reader = _sync_e2e.open_e2e(store)
text = reader.get_file_text(f"{_NEW}/{device_id()}/food_log.json")
assert text is not None
assert _PRIVATE_MEAL in text
class TestMigration:
def test_old_plaintext_peer_log_is_copied_and_merged(self) -> None:
peer = _peer_log("peer porridge")
store = DictStore({f"{_OLD}/phone/food_log.json": peer})
_run(store)
assert "peer porridge" in _all_meals()
# Copied under E2E, and the old node left exactly as it was.
assert "porridge" not in store.files[f"{_NEW}/phone/food_log.json"]
assert store.files[f"{_OLD}/phone/food_log.json"] == peer
def test_old_plaintext_export_keys_move_under_e2e(self) -> None:
key_b64 = "c2VjcmV0LWV4cG9ydC1rZXktYnl0ZXMtMzItbG9uZyE="
keyring = {
"kid1": {
"id": "kid1",
"fields": {"k": [key_b64, "2026-11-01T00:00:00.000Z-0000-phone"]},
"deleted": False,
}
}
old_path = f"{_OLD}/phone/export_keys.json"
store = DictStore({old_path: json.dumps(keyring)})
_run(store)
sealed = store.files[f"{_NEW}/phone/export_keys.json"]
assert key_b64 not in sealed
assert store.files[old_path] == json.dumps(keyring)
reader = _sync_e2e.open_e2e(store)
assert key_b64 in (reader.get_file_text(f"{_NEW}/phone/export_keys.json") or "")
class TestLocked:
def test_no_passphrase_fails_closed_without_any_write(self, tmp_path: Path) -> None:
log_meal(_PRIVATE_MEAL, _nutrition(), slot=480)
store = DictStore()
missing = tmp_path / "no-such-password"
with (
patch.object(_sync_e2e, "PASSWORD_FILE", missing),
pytest.raises(_sync.SyncError, match="locked"),
):
_run(store)
assert store.puts == []
def test_wrong_passphrase_fails_closed(self, tmp_path: Path) -> None:
store = DictStore()
# Another device creates the header under the test passphrase.
with patch.object(_sync_e2e, "E2E_STATE_DIR", tmp_path / "other-device"):
_sync_e2e.open_e2e(store)
store.puts.clear()
other = tmp_path / "other-password"
other.write_text("a different passphrase")
with (
patch.object(_sync_e2e, "PASSWORD_FILE", other),
pytest.raises(_sync.SyncError, match="locked"),
):
_run(store)
assert store.puts == []
def test_locked_pull_reports_a_reason_instead_of_raising(
self, tmp_path: Path
) -> None:
_write_token()
with (
patch.object(_sync_e2e, "PASSWORD_FILE", tmp_path / "none"),
patch.object(_sync_client, "GitHubSyncClient", return_value=DictStore()),
):
reason = _sync.pull_shared_log()
assert reason is not None
assert "locked" in reason
def test_unreadable_header_is_locked(self) -> None:
store = DictStore({"diet-guard-sync/e2e": "not a header"})
with pytest.raises(_sync.SyncError, match="locked"):
_run(store)
class TestUndecryptablePeer:
def test_peer_sealed_under_another_key_is_skipped(
self, caplog: pytest.LogCaptureFixture
) -> None:
log_meal("mine", _nutrition(), slot=480)
layout = E2eLayout.of(_OLD)
path = f"{_NEW}/stranger/food_log.json"
record = {
"id": "r1",
"fields": {"desc": ["x", "2026-11-01T00:00:00.000Z-0000-s"]},
}
alien = seal_log_text(
json.dumps({"r1": record}),
E2eCipher(b"\x01" * 32),
path=path,
)
store = DictStore({path: alien})
with caplog.at_level(logging.WARNING):
_run(store)
assert layout.prefix == _NEW
assert "skipping" in caplog.text
assert _all_meals() == ["mine"]
_COPY = "crdt_sync._e2e_store.copy_to_encrypted"
class TestCopyMemo:
def _store_with_old_revs(self) -> DictStore:
return DictStore(
{
f"{_OLD}/phone/budget.json": "{}",
"diet-guard-sync/revs/phone": "rev-1",
}
)
def test_a_new_process_skips_the_copy_when_old_revisions_are_unchanged(
self,
) -> None:
store = self._store_with_old_revs()
_sync_e2e.open_e2e(store)
with (
patch.dict("crdt_sync._e2e_store._copied", clear=True),
patch(_COPY, return_value=0) as copy,
):
_sync_e2e.open_e2e(store)
copy.assert_not_called()
def test_changed_old_revisions_copy_again(self) -> None:
store = self._store_with_old_revs()
_sync_e2e.open_e2e(store)
store.files["diet-guard-sync/revs/phone"] = "rev-2"
with patch(_COPY, return_value=1) as copy:
_sync_e2e.open_e2e(store)
copy.assert_called_once()
def test_the_cached_key_opens_without_the_passphrase(self, tmp_path: Path) -> None:
store = DictStore()
_sync_e2e.open_e2e(store)
with patch.object(_sync_e2e, "PASSWORD_FILE", tmp_path / "gone"):
assert _sync_e2e.open_e2e(store).layout.prefix == _NEW
def test_wake_alarm_copies_once_per_process(self) -> None:
store = DictStore()
_sync_e2e.open_e2e(store, _sync_e2e.LEGACY_WAKE_ALARM_DIR)
assert not (_sync_e2e.E2E_STATE_DIR).exists()
with (
patch.dict("crdt_sync._e2e_store._copied", clear=True),
patch(_COPY, return_value=0) as copy,
):
_sync_e2e.open_e2e(store, _sync_e2e.LEGACY_WAKE_ALARM_DIR)
copy.assert_called_once()
def test_a_copy_meeting_an_unopenable_blob_is_locked(self) -> None:
store = DictStore()
with (
patch(_COPY, side_effect=E2eDecryptError("bad")),
pytest.raises(E2eLockedError),
):
_sync_e2e.open_e2e(store)
class TestWakeAlarmIngest:
def test_old_plaintext_alarm_is_copied_and_read_decrypted(self) -> None:
alarm = json.dumps(
{
"alarm": {
"id": "alarm",
"fields": {"alarm_hour": [7, "2026-11-01T00:00:00.000Z-0000-p"]},
"deleted": False,
}
}
)
store = DictStore({"wake-alarm-sync/devices/p1/alarm.json": alarm})
diet = _sync_e2e.open_e2e(store)
published = fetch_alarm_logs(diet)
assert [device for device, _ in published] == ["p1"]
assert json.loads(published[0][1])["alarm"]["fields"]["alarm_hour"][0] == 7
sealed = store.files["wake-alarm-sync/devices-e2e/p1/alarm.json"]
assert "alarm_hour" not in sealed
assert "wake-alarm-sync/e2e" in store.files
def test_locked_wake_alarm_is_skipped(self, tmp_path: Path) -> None:
store = DictStore()
diet = _sync_e2e.open_e2e(store)
with patch.object(_sync_e2e, "PASSWORD_FILE", tmp_path / "gone"):
assert fetch_alarm_logs(diet) == []
def test_reads_only_the_firebase_half_of_a_mirror(self) -> None:
primary = DictStore()
mirror = DictStore()
client = MirrorSyncClient(primary, mirror)
diet = _sync_e2e.open_e2e(client)
mirror.puts.clear()
fetch_alarm_logs(diet)
assert not any(p.startswith("wake-alarm-sync") for p in mirror.puts)
class TestPrimaryOnly:
def test_e2e_store_reads_the_primary_decrypted(self) -> None:
primary = DictStore()
mirror = DictStore()
diet = _sync_e2e.open_e2e(MirrorSyncClient(primary, mirror))
path = f"{_NEW}/peer/body.json"
diet.put_file_text(path, '{"x": 1}', message="t")
mirror.files.pop(path)
assert _primary_only_text(diet, path) == '{"x": 1}'
assert '"x"' not in primary.files[path]
def test_plain_store_is_read_directly(self) -> None:
store = DictStore({"a/b": "text"})
assert _primary_only_text(store, "a/b") == "text"

View File

@ -93,7 +93,7 @@ def test_a_peer_credential_is_cached_locally() -> None:
"""Receiving a peer's credential is what makes a wiped device work again."""
client = _client(
{
"diet-guard-sync/devices-e2e/phone/kuchnia.json": _peer_push(
"diet-guard-sync/devices/phone/kuchnia.json": _peer_push(
"peer@example.com", "peer-pass", 1_800_000_000_000
)
}
@ -109,7 +109,7 @@ def test_an_unchanged_credential_is_not_rewritten() -> None:
"""Re-running the tick must not rewrite the file every time."""
client = _client(
{
"diet-guard-sync/devices-e2e/phone/kuchnia.json": _peer_push(
"diet-guard-sync/devices/phone/kuchnia.json": _peer_push(
"peer@example.com", "peer-pass", 1_800_000_000_000
)
}
@ -127,8 +127,8 @@ def test_an_unparsable_peer_file_is_skipped(
"""One bad peer file must not take out the tick for every other device."""
client = _client(
{
"diet-guard-sync/devices-e2e/bad/kuchnia.json": "{not json",
"diet-guard-sync/devices-e2e/phone/kuchnia.json": _peer_push(
"diet-guard-sync/devices/bad/kuchnia.json": "{not json",
"diet-guard-sync/devices/phone/kuchnia.json": _peer_push(
"peer@example.com", "peer-pass", 1_800_000_000_000
),
}
@ -146,7 +146,7 @@ def test_a_missing_peer_file_is_skipped() -> None:
_write_handwritten("me@example.com\nhunter2\n")
_sync_kuchnia_credential(client, ["phone"])
client.get_file_text.assert_called_once_with(
"diet-guard-sync/devices-e2e/phone/kuchnia.json"
"diet-guard-sync/devices/phone/kuchnia.json"
)
@ -197,7 +197,7 @@ def test_a_touched_handwritten_file_cannot_clobber_a_peer() -> None:
phone_ms = int((time.time() - 3600) * 1000)
client = _client(
{
"diet-guard-sync/devices-e2e/phone/kuchnia.json": _peer_push(
"diet-guard-sync/devices/phone/kuchnia.json": _peer_push(
"phone@example.com", "new-phone-pass", phone_ms
)
}

View File

@ -119,7 +119,7 @@ class TestPullSkipsUnchangedPeers:
text = json.dumps({})
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": text},
files={"diet-guard-sync/devices/phone/food_log.json": text},
)
state = SyncState(pushed_rev=None, peer_revs={"phone": "rev-1"})
@ -152,11 +152,8 @@ class TestNoOpPushSuppression:
_sync.run_sync()
pushed_second = [call.args[0] for call in client.put_file_text.call_args_list]
assert f"diet-guard-sync/devices/{device_id()}/food_log.json" in pushed_first
assert (
f"diet-guard-sync/devices-e2e/{device_id()}/food_log.json" in pushed_first
f"diet-guard-sync/devices/{device_id()}/food_log.json" not in pushed_second
)
assert (
f"diet-guard-sync/devices-e2e/{device_id()}/food_log.json"
not in pushed_second
)
assert f"diet-guard-sync/revs-e2e/{device_id()}" not in pushed_second
assert f"diet-guard-sync/revs/{device_id()}" not in pushed_second

View File

@ -26,12 +26,7 @@ from diet_guard._device import device_id
from diet_guard._state import log_meal
from diet_guard._state_sync import read_raw_log, write_raw_log
from diet_guard.sync_merge import daylog_to_log
from diet_guard.tests._sync_fakes import (
_data_paths,
_mock_client,
_nutrition,
_write_token,
)
from diet_guard.tests._sync_fakes import _mock_client, _nutrition, _write_token
def _state_store() -> FileSyncStateStore:
@ -128,7 +123,7 @@ class TestRefreshPeerLogs:
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync_refresh.refresh_peer_logs()
assert _data_paths(client.get_file_text) == []
client.get_file_text.assert_not_called()
def test_skips_this_device(self) -> None:
"""Merging our own pushed log back in is pure waste."""
@ -138,7 +133,7 @@ class TestRefreshPeerLogs:
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync_refresh.refresh_peer_logs()
assert _data_paths(client.get_file_text) == []
client.get_file_text.assert_not_called()
def test_a_missing_remote_file_is_skipped(self) -> None:
_write_token()
@ -154,7 +149,7 @@ class TestRefreshPeerLogs:
_write_token()
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": "{not json"},
files={"diet-guard-sync/devices/phone/food_log.json": "{not json"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
@ -167,7 +162,7 @@ class TestRefreshPeerLogs:
text = _peer_log_text()
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": text},
files={"diet-guard-sync/devices/phone/food_log.json": text},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
@ -185,14 +180,13 @@ class TestRefreshPeerLogs:
text = _peer_log_text()
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": text},
files={"diet-guard-sync/devices/phone/food_log.json": text},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync_refresh.refresh_peer_logs()
# The encryption header may be created on a first-ever open; no data.
assert _data_paths(client.put_file_text) == []
client.put_file_text.assert_not_called()
def test_preserves_pushed_rev(self) -> None:
"""Overwriting it would make this device stop pushing forever."""
@ -202,7 +196,7 @@ class TestRefreshPeerLogs:
text = _peer_log_text()
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": text},
files={"diet-guard-sync/devices/phone/food_log.json": text},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
@ -218,7 +212,7 @@ class TestRefreshPeerLogs:
text = _peer_log_text()
client = _mock_client(
devices=("phone", "other"),
files={"diet-guard-sync/devices-e2e/phone/food_log.json": text},
files={"diet-guard-sync/devices/phone/food_log.json": text},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):

View File

@ -49,11 +49,8 @@ Chrome window answer errors with a status code and an empty body.
- Formula proper names painted outside the activity sheet: `app/lib/widgets/body/body_stats_cards.dart`, `goal_card.dart` (BMI Prime, Trefethen, ideal-weight and resting-burn formula names). The Tk gate dropped them.
- Workout names (`session.label`, `session.kind`) are PC-published content.
## Framework pickers (replaced 2026-11-10)
## Framework pickers (not reworked; follow-up task filed)
`showDatePicker`, `showDateRangePicker` and `showTimePicker` painted Select
date, Cancel, OK, Save. All five call sites now use the icon-only
`app/lib/widgets/pickers/` (numerals, check and close icons), guarded by
`app/test/pickers/no_words_pickers_test.dart` and a `repo_invariants_test`
ban. Lost on purpose: the weekday header (Monday-first layout plus a weekend
dot instead), month names (numerals 1-12) and both text-entry modes.
`showDatePicker`, `showDateRangePicker`, `showTimePicker` paint Select date,
Cancel, OK, Save: `log_meal_future_mixin.dart`, `history_filter_sheet.dart`,
`dated_value_card.dart`, `meal_time_dropdown.dart`, `profile_card.dart`.

View File

@ -4,11 +4,11 @@ version = "1.0.0"
description = "Tkinter/systemd log-to-unlock meal gate with calorie/macro budget tracking"
requires-python = ">=3.14"
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.13.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.11.0#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.2#subdirectory=freedays",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
"holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.5.3#subdirectory=sickdays",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
"defusedxml==0.7.1",
"requests==2.34.2",
]

View File

@ -3,10 +3,10 @@
bandit==1.9.4
codespell==2.4.3
coverage==7.17.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.11.0#subdirectory=crdt-sync
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
defusedxml==0.7.1
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.2#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.13.0#subdirectory=gatelock
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds
mcp==2.3.0
mypy==2.4.1
@ -21,4 +21,4 @@ pytest-xdist==3.8.0
pytest-xvfb==3.1.1
requests==2.34.2
ruff==0.17.4
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.5.3#subdirectory=sickdays
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays