mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-12 20:40:48 +01:00
Compare commits
No commits in common. "main" and "v1.0.147" have entirely different histories.
@ -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
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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)),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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))}),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({})
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user