mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
Body tab: weight/body-fat logs, BMI, ideal weight, goal-based kcal target, workouts + Health Connect steps, week planner
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4b1de83e27
commit
e761926df4
@ -28,7 +28,7 @@ repos:
|
||||
args: [--pytest-test-first]
|
||||
# Shared fakes and fixtures, not test modules. Split out of conftest.py
|
||||
# and test_sync.py only because those files passed the 250-line cap.
|
||||
exclude: ^diet_guard/tests/_(tk_fakes(_containers|_canvas)?|gate_layout_probe|gate_focus_probe|gate_measurement|gate_keyboard_probe|gate_fixtures|sync_fakes|kuchnia_fakes)\.py$
|
||||
exclude: ^diet_guard/tests/_(tk_fakes(_containers|_canvas)?|gate_layout_probe|gate_focus_probe|gate_measurement|gate_keyboard_probe|gate_fixtures|sync_fakes|kuchnia_fakes|state_redirects)\.py$
|
||||
- id: check-ast
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
@ -108,9 +108,10 @@ repos:
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
- defusedxml==0.7.1
|
||||
# Test suites intentionally use patterns (protected-access, magic-value
|
||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||
@ -145,6 +146,8 @@ repos:
|
||||
- id: codespell
|
||||
args:
|
||||
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
||||
# Devine: the 1974 ideal-body-weight formula's author, not "define".
|
||||
- --ignore-words-list=devine
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
20
AGENTS.md
20
AGENTS.md
@ -20,7 +20,7 @@ catch-up rule).
|
||||
- Lint: `pre-commit run --all-files`
|
||||
- Test the lock (safe, closeable): `python -m diet_guard gate --demo`
|
||||
- One sync tick: `python -m diet_guard sync`
|
||||
- Averages: `python -m diet_guard averages`
|
||||
- Averages: `python -m diet_guard averages`; Body: `body`, `weight KG`, `profile`, `plan-week`
|
||||
- Prune dead sync peers (dry run): `python -m diet_guard prune-peers`
|
||||
- Install for production: `bash install.sh`
|
||||
- App tests: `cd app && flutter test`
|
||||
@ -67,8 +67,8 @@ every idle tick to approximate the two moments state actually changes here.
|
||||
demo already writes real entries through `log_meal` (only the *slot* is
|
||||
synthetic).
|
||||
- `pull_shared_log()` is misnamed: it runs a *full* tick (pull, merge, re-sign,
|
||||
persist, **push**, budget, both food banks, and last the phone's weigh-in
|
||||
into `w` via `_phone_weight`: `wake_alarm._weight`, lazy, never raises).
|
||||
persist, **push**, budget, both food banks, the Body doc via `_sync_body`,
|
||||
and last the newest logged weight into `w` via `_phone_weight`).
|
||||
`_sync_refresh.pull_peer_logs()` is for anything a user waits on. What must
|
||||
follow every tick goes in `run_sync`, not `cmd_sync` (the PC never runs it).
|
||||
- Accepted trade: a PC neither logging nor locking does not pull.
|
||||
@ -79,8 +79,8 @@ every idle tick to approximate the two moments state actually changes here.
|
||||
`_sync_refresh.py`, the fetch button, or the publish paths.
|
||||
- Every test that logs a meal would otherwise hit the network through these new
|
||||
call sites; `conftest._isolate_state` patches each one
|
||||
(`_cli_log.publish_after_log_detached`, `_cli_gate.publish_after_log`,
|
||||
`_mcp.publish_after_log`, `_cli_gate.pull_peer_logs`) — not on
|
||||
(listed in `tests/_state_redirects.py`: `_cli_log`, `_cli_gate`, `_mcp`,
|
||||
`_cli_body` publish sites and `_cli_gate.pull_peer_logs`) — not on
|
||||
`_sync_events`, so a direct test still exercises the real helper. A new
|
||||
network entry point missing from that list reaches the real remote.
|
||||
|
||||
@ -139,14 +139,14 @@ reach the running service.
|
||||
`test_budget_history.py`; it syncs as `hist:<YYYY-MM-DD>` fields on the
|
||||
existing `budget` record, so devices predating the feature relay them
|
||||
untouched.
|
||||
- **Biometrics are used once and discarded** — only the computed budget is
|
||||
persisted.
|
||||
- **Body data persists and syncs** (profile, weight log, PC workouts) as
|
||||
`body.json`; its tables are informational. [docs/DOCS-body.md](docs/DOCS-body.md).
|
||||
- **PC and phone share one source of truth for everything**: food log, budget,
|
||||
budget history, body weight (`w`), curated food bank. If you add a stored
|
||||
budget history, body weight (`w`), Body doc, curated food bank. If you add a stored
|
||||
field, sync it or comment why it physically cannot be.
|
||||
- **State lives entirely under `~/.local/share/diet_guard/`** — no cross-repo
|
||||
file coupling. Exception: the sync timer touches `kuhyx/syncs` and
|
||||
`~/.config/diet_guard/sync_token`.
|
||||
file coupling. Exceptions: sync (`kuhyx/syncs`, `~/.config/diet_guard/`) and
|
||||
read-only workout sources (RunnerUp drop, screen-locker: `_activity_sources`).
|
||||
- **Every device pushes under a persisted per-install uuid**, not a role
|
||||
constant. `_device.py` / `sync_device_id.dart` (`crdt.nodeId`). The old role
|
||||
constant survives as the legacy id so `devices/<legacy>/` is skipped as this
|
||||
|
||||
@ -39,7 +39,8 @@ android {
|
||||
applicationId = "com.kuhy.diet_guard_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
// Health Connect (the `health` plugin) needs API 26.
|
||||
minSdk = maxOf(26, flutter.minSdkVersion)
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
@ -13,6 +13,10 @@
|
||||
(permission_handler's ignoreBatteryOptimizations) to actually show
|
||||
the system dialog instead of silently returning denied. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
<!-- Health Connect steps for the Body screen (health_steps.dart). Read
|
||||
only, foreground only: no READ_HEALTH_DATA_IN_BACKGROUND, so the
|
||||
WorkManager isolate never reads Health Connect. -->
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS"/>
|
||||
<!-- Deliberately NOT requesting SCHEDULE_EXACT_ALARM: the background
|
||||
check is a 15-minute WorkManager periodic task, not exact alarms
|
||||
(more robust against OEM background-kill; +/-15 min is accepted, see
|
||||
@ -44,7 +48,23 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Health Connect (Android 13 and below) opens this to show why
|
||||
the app wants steps; see the `health` plugin README. -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Health Connect (Android 14+) links "view permission usage" here. -->
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@ -61,5 +81,10 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- Lets the `health` plugin see whether Health Connect is installed. -->
|
||||
<package android:name="com.google.android.apps.healthdata"/>
|
||||
<intent>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package com.kuhy.diet_guard_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
// FlutterFragmentActivity, not FlutterActivity: the `health` plugin requests
|
||||
// Health Connect permissions through registerForActivityResult, which needs a
|
||||
// ComponentActivity (see the plugin README, "Android 14").
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
|
||||
@ -6,6 +6,7 @@ import 'dart:async';
|
||||
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/background_tasks.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/frame_stats.dart';
|
||||
@ -29,6 +30,7 @@ Future<void> main() async {
|
||||
await MealScheduleService.init();
|
||||
await FoodBankService.init();
|
||||
await KuchniaCredentialService.init();
|
||||
await BodyService.init();
|
||||
await KuchniaQueueService.init();
|
||||
final notifications = await NotificationService.init();
|
||||
// Deliberately *not* awaited before the first frame: the browser's
|
||||
|
||||
142
app/lib/models/body_document.dart
Normal file
142
app/lib/models/body_document.dart
Normal file
@ -0,0 +1,142 @@
|
||||
/// The synced Body document: profile, per-day weights, published workouts.
|
||||
///
|
||||
/// Same JSON shape as `~/.local/share/diet_guard/body.json` on the PC (see
|
||||
/// `docs/DOCS-body.md`). Every field carries its own edit stamp `t`, which is
|
||||
/// what the sync merge derives its clocks from. Parsing is tolerant: a
|
||||
/// malformed part is dropped, never raised, so one bad field from a peer
|
||||
/// cannot take out the rest.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/body_entries.dart';
|
||||
|
||||
export 'package:diet_guard_app/models/body_entries.dart';
|
||||
|
||||
/// Profile field names.
|
||||
const String profileBirth = 'birth';
|
||||
|
||||
/// Profile height field.
|
||||
const String profileHeight = 'height_cm';
|
||||
|
||||
/// Profile sex field (`m` / `f`).
|
||||
const String profileSex = 'sex';
|
||||
|
||||
/// The whole document.
|
||||
class BodyDocument {
|
||||
/// Creates a document.
|
||||
const BodyDocument({
|
||||
this.profile = const {},
|
||||
this.weights = const {},
|
||||
this.bodyFat = const {},
|
||||
this.steps = const {},
|
||||
this.activity,
|
||||
});
|
||||
|
||||
/// Parses the local document JSON (already decoded).
|
||||
factory BodyDocument.fromJson(Object? raw) {
|
||||
if (raw is! Map) return const BodyDocument();
|
||||
final profile = <String, Stamped>{};
|
||||
final rawProfile = raw['profile'];
|
||||
// Every stamped field is kept, known or not: a field a newer peer added
|
||||
// (the table prefs were one) must survive this device's re-push.
|
||||
if (rawProfile is Map) {
|
||||
rawProfile.forEach((name, field) {
|
||||
if (name is String && field is Map && field['t'] is String) {
|
||||
profile[name] = Stamped(field['v'], field['t'] as String);
|
||||
}
|
||||
});
|
||||
}
|
||||
final weights = <String, WeightEntry>{};
|
||||
final rawWeights = raw['weights'];
|
||||
if (rawWeights is Map) {
|
||||
rawWeights.forEach((day, value) {
|
||||
final entry = parseWeightValue(value, value is Map ? value['t'] : null);
|
||||
if (isDateKey(day) && entry != null) weights[day as String] = entry;
|
||||
});
|
||||
}
|
||||
final bodyFat = <String, BodyFatEntry>{};
|
||||
final rawFat = raw['bodyfat'];
|
||||
if (rawFat is Map) {
|
||||
rawFat.forEach((day, value) {
|
||||
final t = value is Map ? value['t'] : null;
|
||||
final entry = parseBodyFatValue(value, t);
|
||||
if (isDateKey(day) && entry != null) bodyFat[day as String] = entry;
|
||||
});
|
||||
}
|
||||
final steps = <String, StepsEntry>{};
|
||||
final rawSteps = raw['steps'];
|
||||
if (rawSteps is Map) {
|
||||
rawSteps.forEach((day, value) {
|
||||
final t = value is Map ? value['t'] : null;
|
||||
final entry = parseStepsValue(value, t);
|
||||
if (isDateKey(day) && entry != null) steps[day as String] = entry;
|
||||
});
|
||||
}
|
||||
final rawActivity = raw['activity'];
|
||||
ActivityData? activity;
|
||||
if (rawActivity is Map &&
|
||||
rawActivity['sessions'] is List &&
|
||||
rawActivity['t'] is String) {
|
||||
activity = ActivityData(
|
||||
sessions: List<Object?>.from(rawActivity['sessions'] as List),
|
||||
t: rawActivity['t'] as String,
|
||||
);
|
||||
}
|
||||
return BodyDocument(
|
||||
profile: profile,
|
||||
weights: weights,
|
||||
bodyFat: bodyFat,
|
||||
steps: steps,
|
||||
activity: activity,
|
||||
);
|
||||
}
|
||||
|
||||
/// Profile fields by name.
|
||||
final Map<String, Stamped> profile;
|
||||
|
||||
/// Weights by `YYYY-MM-DD`.
|
||||
final Map<String, WeightEntry> weights;
|
||||
|
||||
/// Body fat readings by `YYYY-MM-DD`.
|
||||
final Map<String, BodyFatEntry> bodyFat;
|
||||
|
||||
/// Steps outside workouts by `YYYY-MM-DD` (written by the phone).
|
||||
final Map<String, StepsEntry> steps;
|
||||
|
||||
/// Workouts published by the PC, or null when none ever were.
|
||||
final ActivityData? activity;
|
||||
|
||||
/// Returns a copy with the given parts replaced.
|
||||
BodyDocument copyWith({
|
||||
Map<String, Stamped>? profile,
|
||||
Map<String, WeightEntry>? weights,
|
||||
Map<String, BodyFatEntry>? bodyFat,
|
||||
Map<String, StepsEntry>? steps,
|
||||
ActivityData? activity,
|
||||
}) => BodyDocument(
|
||||
profile: profile ?? this.profile,
|
||||
weights: weights ?? this.weights,
|
||||
bodyFat: bodyFat ?? this.bodyFat,
|
||||
steps: steps ?? this.steps,
|
||||
activity: activity ?? this.activity,
|
||||
);
|
||||
|
||||
/// JSON form of the local document.
|
||||
Map<String, Object?> toJson() => {
|
||||
'v': 1,
|
||||
'profile': {
|
||||
for (final entry in profile.entries)
|
||||
entry.key: {'v': entry.value.value, 't': entry.value.t},
|
||||
},
|
||||
'weights': {
|
||||
for (final entry in weights.entries) entry.key: entry.value.toJson(),
|
||||
},
|
||||
'bodyfat': {
|
||||
for (final entry in bodyFat.entries) entry.key: entry.value.toJson(),
|
||||
},
|
||||
'steps': {
|
||||
for (final entry in steps.entries) entry.key: entry.value.toJson(),
|
||||
},
|
||||
if (activity != null)
|
||||
'activity': {'sessions': activity!.sessions, 't': activity!.t},
|
||||
};
|
||||
}
|
||||
132
app/lib/models/body_entries.dart
Normal file
132
app/lib/models/body_entries.dart
Normal file
@ -0,0 +1,132 @@
|
||||
/// The Body document's parts: stamped values, dated entries, parsers.
|
||||
///
|
||||
/// Split out of `body_document.dart` for the 250-line cap; that file
|
||||
/// re-exports this one, so importers see no change. Parsing is tolerant: a
|
||||
/// malformed part is dropped (null), never raised.
|
||||
library;
|
||||
|
||||
/// A value and the ISO time it was last edited.
|
||||
class Stamped {
|
||||
/// Creates a stamped value.
|
||||
const Stamped(this.value, this.t);
|
||||
|
||||
/// The value.
|
||||
final Object? value;
|
||||
|
||||
/// ISO-8601 edit time.
|
||||
final String t;
|
||||
}
|
||||
|
||||
/// One day's weight. [kg] null is a tombstone (a deleted day).
|
||||
class WeightEntry {
|
||||
/// Creates an entry.
|
||||
const WeightEntry({required this.kg, required this.src, required this.t});
|
||||
|
||||
/// Kilograms, or null when deleted.
|
||||
final double? kg;
|
||||
|
||||
/// `phone`, `manual` or `init`.
|
||||
final String src;
|
||||
|
||||
/// ISO-8601 edit time.
|
||||
final String t;
|
||||
|
||||
/// JSON form of the local document.
|
||||
Map<String, Object?> toJson() => {'kg': kg, 'src': src, 't': t};
|
||||
}
|
||||
|
||||
/// One day's body fat %. [pct] null is a tombstone (a deleted day).
|
||||
class BodyFatEntry {
|
||||
/// Creates an entry.
|
||||
const BodyFatEntry({required this.pct, required this.t});
|
||||
|
||||
/// Percent, or null when deleted.
|
||||
final double? pct;
|
||||
|
||||
/// ISO-8601 edit time.
|
||||
final String t;
|
||||
|
||||
/// JSON form of the local document.
|
||||
Map<String, Object?> toJson() => {'pct': pct, 't': t};
|
||||
}
|
||||
|
||||
/// One day's steps outside workouts, as the phone read them.
|
||||
class StepsEntry {
|
||||
/// Creates an entry.
|
||||
const StepsEntry({required this.n, required this.t});
|
||||
|
||||
/// Step count.
|
||||
final int n;
|
||||
|
||||
/// ISO-8601 edit time.
|
||||
final String t;
|
||||
|
||||
/// JSON form of the local document.
|
||||
Map<String, Object?> toJson() => {'n': n, 't': t};
|
||||
}
|
||||
|
||||
/// Workouts published by the PC.
|
||||
class ActivityData {
|
||||
/// Creates the published activity.
|
||||
const ActivityData({required this.sessions, required this.t});
|
||||
|
||||
/// Raw session objects, as on the wire.
|
||||
final List<Object?> sessions;
|
||||
|
||||
/// ISO-8601 time the PC last changed them.
|
||||
final String t;
|
||||
}
|
||||
|
||||
/// Parses an ISO stamp to epoch milliseconds; unparsable -> 0 (the epoch).
|
||||
int stampMillis(String t) => DateTime.tryParse(t)?.millisecondsSinceEpoch ?? 0;
|
||||
|
||||
/// Whether [raw] is a usable weight in kilograms.
|
||||
bool plausibleKg(Object? raw) => raw is num && raw >= 20 && raw <= 400;
|
||||
|
||||
/// Body fat below/above this (%) is a typo, not a measurement.
|
||||
const double minBodyFat = 3;
|
||||
|
||||
/// Upper bound of a plausible body fat %.
|
||||
const double maxBodyFat = 70;
|
||||
|
||||
/// Whether [raw] is a usable body fat %.
|
||||
bool plausibleBodyFat(Object? raw) =>
|
||||
raw is num && raw >= minBodyFat && raw <= maxBodyFat;
|
||||
|
||||
final RegExp _dateKey = RegExp(r'^\d{4}-\d{2}-\d{2}$');
|
||||
|
||||
/// Whether [raw] is a `YYYY-MM-DD` date key.
|
||||
bool isDateKey(Object? raw) => raw is String && _dateKey.hasMatch(raw);
|
||||
|
||||
/// Parses a weight value `{kg, src}` stamped [t], or null when malformed.
|
||||
///
|
||||
/// `kg` must be null (a tombstone) or a plausible number.
|
||||
WeightEntry? parseWeightValue(Object? value, Object? t) {
|
||||
if (value is! Map || t is! String) return null;
|
||||
final kg = value['kg'];
|
||||
if (kg != null && !plausibleKg(kg)) return null;
|
||||
final src = value['src'];
|
||||
return WeightEntry(
|
||||
kg: (kg as num?)?.toDouble(),
|
||||
src: src is String ? src : 'manual',
|
||||
t: t,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses a body-fat value `{pct}` stamped [t], or null when malformed.
|
||||
///
|
||||
/// `pct` must be null (a tombstone) or a plausible number.
|
||||
BodyFatEntry? parseBodyFatValue(Object? value, Object? t) {
|
||||
if (value is! Map || t is! String) return null;
|
||||
final pct = value['pct'];
|
||||
if (pct != null && !plausibleBodyFat(pct)) return null;
|
||||
return BodyFatEntry(pct: (pct as num?)?.toDouble(), t: t);
|
||||
}
|
||||
|
||||
/// Parses a steps value `{n}` stamped [t], or null when malformed.
|
||||
StepsEntry? parseStepsValue(Object? value, Object? t) {
|
||||
if (value is! Map || t is! String) return null;
|
||||
final n = value['n'];
|
||||
if (n is! num || n < 0) return null;
|
||||
return StepsEntry(n: n.toInt(), t: t);
|
||||
}
|
||||
72
app/lib/models/workout_session.dart
Normal file
72
app/lib/models/workout_session.dart
Normal file
@ -0,0 +1,72 @@
|
||||
/// One published workout, as the PC syncs it in the Body document.
|
||||
///
|
||||
/// Split out of `services/activity_kcal.dart` for the 250-line cap; that
|
||||
/// file re-exports it, so importers see no change. Parsing is tolerant: a
|
||||
/// malformed session is dropped, an unparsable time reads as null.
|
||||
library;
|
||||
|
||||
/// One workout, reduced to what the energy formulas need.
|
||||
class Session {
|
||||
/// Creates a session.
|
||||
const Session({
|
||||
required this.day,
|
||||
required this.kind,
|
||||
required this.minutes,
|
||||
required this.km,
|
||||
required this.label,
|
||||
required this.source,
|
||||
this.start,
|
||||
this.end,
|
||||
});
|
||||
|
||||
/// Parses the synced wire object (`day, kind, min, km, label, src`), or
|
||||
/// null when it is malformed.
|
||||
static Session? fromWire(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final day = raw['day'];
|
||||
final kind = raw['kind'];
|
||||
final minutes = raw['min'];
|
||||
final km = raw['km'];
|
||||
if (day is! String || kind is! String || minutes is! num) return null;
|
||||
if (km != null && km is! num) return null;
|
||||
return Session(
|
||||
day: day,
|
||||
kind: kind,
|
||||
minutes: minutes.toDouble(),
|
||||
km: (km as num?)?.toDouble(),
|
||||
label: raw['label'] is String ? raw['label'] as String : kind,
|
||||
source: raw['src'] is String ? raw['src'] as String : '',
|
||||
start: _stampOrNull(raw['start']),
|
||||
end: _stampOrNull(raw['end']),
|
||||
);
|
||||
}
|
||||
|
||||
/// Local `YYYY-MM-DD` the workout happened on.
|
||||
final String day;
|
||||
|
||||
/// `strength`, `run`, `walk`, `cycle` or `other`.
|
||||
final String kind;
|
||||
|
||||
/// Duration in minutes.
|
||||
final double minutes;
|
||||
|
||||
/// Distance, or null when the source has none.
|
||||
final double? km;
|
||||
|
||||
/// Human description.
|
||||
final String label;
|
||||
|
||||
/// Where it came from (`runnerup`, `screen-locker`).
|
||||
final String source;
|
||||
|
||||
/// ISO start time, when the source knows it.
|
||||
final String? start;
|
||||
|
||||
/// ISO end time, when the source knows it. The phone drops Health
|
||||
/// Connect steps inside `start..end`, so a walk is not counted twice.
|
||||
final String? end;
|
||||
}
|
||||
|
||||
/// An ISO time string, or null when absent or unparsable.
|
||||
String? _stampOrNull(Object? value) =>
|
||||
value is String && DateTime.tryParse(value) != null ? value : null;
|
||||
213
app/lib/screens/body_screen.dart
Normal file
213
app/lib/screens/body_screen.dart
Normal file
@ -0,0 +1,213 @@
|
||||
/// The Body screen: the goal sentence first, then weight (+ graph), BMI,
|
||||
/// ideal weight, BMR, measured activity and steps, the week planner, the
|
||||
/// profile (at the top instead while incomplete), and body fat last.
|
||||
///
|
||||
/// The phone half of the PC gate's Body tab (`_gatelock_body*.py`), reading
|
||||
/// the same synced `body.json` (see `docs/DOCS-body.md`). **Informational
|
||||
/// only**: nothing here writes the daily budget -- there is deliberately no
|
||||
/// "apply" button.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/body_steps.dart';
|
||||
import 'package:diet_guard_app/services/health_steps.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:diet_guard_app/widgets/body/activity_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_stats_cards.dart';
|
||||
import 'package:diet_guard_app/widgets/body/goal_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/profile_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/week_plan_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/weight_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Builds the person from the stored profile and newest weight, or null
|
||||
/// when anything the formulas need is missing.
|
||||
Biometrics? currentBiometrics(BodyService body, DateTime today) {
|
||||
final birth = DateTime.tryParse(body.birth ?? '');
|
||||
final height = body.heightCm;
|
||||
final sex = body.sex;
|
||||
final weight = body.latestWeight;
|
||||
if (birth == null || height == null || sex == null || weight == null) {
|
||||
return null;
|
||||
}
|
||||
return Biometrics(
|
||||
weightKg: weight.$2,
|
||||
heightCm: height,
|
||||
ageYears: ageOn(birth, today).toDouble(),
|
||||
isMale: sex == 'm',
|
||||
);
|
||||
}
|
||||
|
||||
/// Steps outside workouts by day, as stored.
|
||||
Map<String, int> stepsByDay(BodyService body) =>
|
||||
body.document.steps.map((day, entry) => MapEntry(day, entry.n));
|
||||
|
||||
/// Mean daily exercise per formula (steps included), the steps' own share,
|
||||
/// and the number of full days averaged -- like the PC's `_body_view`: the
|
||||
/// two means are null without a person, or without any published workout
|
||||
/// or step count.
|
||||
(Map<String, double>?, double?, int) bodyExercise(
|
||||
BodyService body,
|
||||
Biometrics? bio,
|
||||
DateTime today,
|
||||
) {
|
||||
final sessions = body.sessions;
|
||||
final steps = stepsByDay(body);
|
||||
if (bio == null || (sessions == null && steps.isEmpty)) {
|
||||
return (null, null, 0);
|
||||
}
|
||||
final byDay = steps.map((day, n) => MapEntry(day, stepKcal(n, bio)));
|
||||
final weight = bio.weightKg;
|
||||
final all = sessions ?? const <Session>[];
|
||||
return (
|
||||
dailyExercise(all, weight, today, byDay),
|
||||
extraShare(all, today, byDay),
|
||||
windowDays(all, today, byDay),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sums each day's non-deleted kcal.
|
||||
Map<String, double> kcalByDay(DayLog log) => {
|
||||
for (final entry in log.entries)
|
||||
entry.key: entry.value
|
||||
.where((food) => !food.deleted)
|
||||
.fold<double>(0, (sum, food) => sum + food.kcal),
|
||||
};
|
||||
|
||||
/// The Body screen.
|
||||
class BodyScreen extends StatefulWidget {
|
||||
/// Creates the screen.
|
||||
const BodyScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BodyScreen> createState() => _BodyScreenState();
|
||||
}
|
||||
|
||||
class _BodyScreenState extends State<BodyScreen> {
|
||||
Map<String, double> _logged = const {};
|
||||
|
||||
/// Decided once, when the screen opens: a complete profile sits at the
|
||||
/// bottom, an incomplete one at the top. Re-deciding on every build would
|
||||
/// yank the card away mid-edit the moment the last field is saved; it
|
||||
/// moves the next time the screen is entered.
|
||||
late final bool _profileAtTop = !BodyService.instance.profileComplete;
|
||||
|
||||
/// Whether the Health Connect button shows: supported and not yet granted.
|
||||
bool _canConnectSteps = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_loadLog());
|
||||
unawaited(_loadSteps());
|
||||
}
|
||||
|
||||
/// Refreshes steps (only when already permitted -- never prompts) and
|
||||
/// decides whether to offer the connect button.
|
||||
Future<void> _loadSteps() async {
|
||||
final source = stepsSource;
|
||||
if (!source.supported) return;
|
||||
final granted = await source.hasPermission().catchError((_) => false);
|
||||
if (granted) await refreshSteps();
|
||||
if (!mounted) return;
|
||||
setState(() => _canConnectSteps = !granted);
|
||||
}
|
||||
|
||||
Future<void> _connectSteps() async {
|
||||
final granted = await stepsSource.requestPermission().catchError(
|
||||
(_) => false,
|
||||
);
|
||||
if (granted) await refreshSteps();
|
||||
if (!mounted) return;
|
||||
setState(() => _canConnectSteps = !granted);
|
||||
}
|
||||
|
||||
Future<void> _loadLog() async {
|
||||
final log = await LogStorageService.instance.readLog();
|
||||
if (!mounted) return;
|
||||
setState(() => _logged = kcalByDay(log));
|
||||
}
|
||||
|
||||
/// Any profile/weight write changes every derived card; the cards read
|
||||
/// [BodyService] in `build`, so a rebuild is all they need.
|
||||
void _onChanged() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = BodyService.instance;
|
||||
final today = DateTime.now();
|
||||
final bio = currentBiometrics(body, today);
|
||||
final sessions = body.sessions;
|
||||
final weight = body.latestWeight?.$2;
|
||||
final (exercise, stepsKcal, window) = bodyExercise(body, bio, today);
|
||||
final bodyFat = body.latestBodyFat?.$2;
|
||||
final profile = ProfileCard(onSaved: _onChanged);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Body')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_profileAtTop) profile,
|
||||
if (bio == null)
|
||||
BodySection(
|
||||
title: 'Daily calorie target',
|
||||
children: [BodyNote(_missing(body))],
|
||||
)
|
||||
else
|
||||
GoalCard(bio: bio, exercise: exercise, bodyFatPct: bodyFat),
|
||||
WeightCard(onChanged: _onChanged),
|
||||
if (bio != null) ...[
|
||||
BmiCard(bio: bio),
|
||||
IdealWeightCard(bio: bio),
|
||||
BmrCard(bio: bio, bodyFatPct: bodyFat),
|
||||
],
|
||||
ActivityCard(
|
||||
sessions: sessions,
|
||||
weightKg: weight,
|
||||
exercise: exercise,
|
||||
steps: stepsByDay(body),
|
||||
stepsKcal: stepsKcal,
|
||||
window: window,
|
||||
onConnectSteps: _canConnectSteps ? _connectSteps : null,
|
||||
),
|
||||
WeekPlanCard(
|
||||
defaultTarget: AppSettingsService.dailyKcalGoal,
|
||||
logged: _logged,
|
||||
),
|
||||
if (!_profileAtTop) profile,
|
||||
BodyFatCard(onChanged: _onChanged),
|
||||
BodyNote('Age ${_age(body, today)} · synced with the PC.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _missing(BodyService body) {
|
||||
if (body.latestWeight == null && body.birth == null) {
|
||||
return 'Set your profile and enter your weight to see BMI, ideal '
|
||||
'weight and calorie targets.';
|
||||
}
|
||||
if (body.latestWeight == null) return 'Enter your weight to see the numbers.';
|
||||
return 'Set your profile (birth date, height, sex) to see the numbers.';
|
||||
}
|
||||
|
||||
String _age(BodyService body, DateTime today) {
|
||||
final birth = DateTime.tryParse(body.birth ?? '');
|
||||
return birth == null ? 'unknown' : '${ageOn(birth, today)}';
|
||||
}
|
||||
@ -32,7 +32,7 @@ Nutrition nutritionFromControllers(MacroControllers macros, String source) =>
|
||||
source: source,
|
||||
);
|
||||
|
||||
/// The four destination buttons in [LogMealScreen]'s app bar.
|
||||
/// The five destination buttons in [LogMealScreen]'s app bar.
|
||||
///
|
||||
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. Returned
|
||||
/// as a list to be spread into `AppBar.actions`, so the bar's structure and
|
||||
@ -42,7 +42,13 @@ List<Widget> logMealAppBarActions({
|
||||
required VoidCallback onHistory,
|
||||
required VoidCallback onCalendar,
|
||||
required VoidCallback onSettings,
|
||||
required VoidCallback onBody,
|
||||
}) => [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.monitor_weight),
|
||||
tooltip: 'Body',
|
||||
onPressed: onBody,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.restaurant_menu),
|
||||
tooltip: 'Food bank',
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'package:diet_guard_app/screens/body_screen.dart';
|
||||
import 'package:diet_guard_app/screens/calendar_screen.dart';
|
||||
import 'package:diet_guard_app/screens/food_bank_screen.dart';
|
||||
import 'package:diet_guard_app/screens/history_screen.dart';
|
||||
@ -5,7 +6,7 @@ import 'package:diet_guard_app/screens/settings_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// The four app-bar destinations reachable from [LogMealScreen].
|
||||
/// The five app-bar destinations reachable from [LogMealScreen].
|
||||
///
|
||||
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. The routes
|
||||
/// are unchanged -- each pushes exactly the [MaterialPageRoute] it did before.
|
||||
@ -25,23 +26,30 @@ mixin LogMealNavMixin<T extends StatefulWidget> on State<T> {
|
||||
|
||||
/// Pushes the entry history screen.
|
||||
void onOpenHistory() {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const HistoryScreen()),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const HistoryScreen()));
|
||||
}
|
||||
|
||||
/// Pushes the adherence calendar screen.
|
||||
void onOpenCalendar() {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const CalendarScreen()),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const CalendarScreen()));
|
||||
}
|
||||
|
||||
/// Pushes the Body screen (weight, BMI, calorie targets, week planner).
|
||||
void onOpenBody() {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const BodyScreen()));
|
||||
}
|
||||
|
||||
/// Pushes the food bank screen.
|
||||
void onOpenFoodBank() {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const FoodBankScreen()),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const FoodBankScreen()));
|
||||
}
|
||||
|
||||
/// Pushes the settings screen, forwarding the HTTP client.
|
||||
|
||||
@ -190,6 +190,7 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
onHistory: onOpenHistory,
|
||||
onCalendar: onOpenCalendar,
|
||||
onSettings: onOpenSettings,
|
||||
onBody: onOpenBody,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:diet_guard_app/models/slot.dart';
|
||||
import 'package:diet_guard_app/services/background_tasks.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/health_steps.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_health.dart';
|
||||
@ -91,11 +92,11 @@ mixin LogMealSyncMixin<T extends StatefulWidget> on State<T>
|
||||
if (mounted) await refreshSyncHealth();
|
||||
return;
|
||||
}
|
||||
final client = createGitHubClient(
|
||||
settings,
|
||||
httpClient: syncHttpClient,
|
||||
);
|
||||
final client = createGitHubClient(settings, httpClient: syncHttpClient);
|
||||
try {
|
||||
// Foreground only (this mixin is UI), and never prompts: fresh
|
||||
// Health Connect steps ride along in this tick's body.json.
|
||||
await refreshSteps();
|
||||
await runSync(await syncBackend(client));
|
||||
await SyncHealth.recordSuccess();
|
||||
} finally {
|
||||
|
||||
227
app/lib/services/activity_kcal.dart
Normal file
227
app/lib/services/activity_kcal.dart
Normal file
@ -0,0 +1,227 @@
|
||||
/// Exercise energy per workout, by every formula the workout data supports.
|
||||
///
|
||||
/// Port of `diet_guard/_activity_kcal.py`, gated by the shared
|
||||
/// `tests/fixtures/body_calc.json`. Every formula returns **net** kcal --
|
||||
/// energy above resting -- because the measured TDEE adds exercise on top of
|
||||
/// `BMR x 1.2`, which already pays for the resting hour.
|
||||
///
|
||||
/// * **MET** (Compendium 2011): `(MET - 1) x kg x hours`, speed-interpolated
|
||||
/// for runs and walks; lifting is code 02052 (resistance training) = 5.0.
|
||||
/// * **ACSM** level-ground equations: net VO2 = 0.2 v (run) / 0.1 v (walk),
|
||||
/// v in m/min; kcal = VO2 x kg x min x 5 / 1000.
|
||||
/// * **Per-km**: 0.9 (run) / 0.5 (walk) kcal/kg/km net.
|
||||
///
|
||||
/// ACSM and per-km need a distance, so every other session falls back to
|
||||
/// its MET figure (listed in [SessionKcal.fallback]).
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/workout_session.dart';
|
||||
|
||||
export 'package:diet_guard_app/models/workout_session.dart';
|
||||
|
||||
/// Formula names, in display order.
|
||||
const List<String> activityFormulas = ['MET', 'ACSM', 'Per-km'];
|
||||
|
||||
/// The methods explained, shown under the Activity card.
|
||||
const String methodsNote =
|
||||
'MET = standard activity values x your weight x time. '
|
||||
'ACSM = American College of Sports Medicine running/walking equation '
|
||||
'from your speed. Per-km = ~0.9 kcal per kg per km running, ~0.5 '
|
||||
'walking. All are energy above resting.';
|
||||
|
||||
/// Measured activity averages at most the last two full weeks.
|
||||
const int maxWindowDays = 14;
|
||||
|
||||
const List<(double, double)> _runMetByKmh = [
|
||||
(6.4, 6),
|
||||
(8, 8.3),
|
||||
(8.4, 9),
|
||||
(9.7, 9.8),
|
||||
(10.8, 10.5),
|
||||
(11.3, 11),
|
||||
(12.1, 11.5),
|
||||
(12.9, 11.8),
|
||||
(13.8, 12.3),
|
||||
(14.5, 12.8),
|
||||
(16.1, 14.5),
|
||||
(17.7, 16),
|
||||
(19.3, 19),
|
||||
(20.9, 19.8),
|
||||
(22.5, 23),
|
||||
];
|
||||
const List<(double, double)> _walkMetByKmh = [
|
||||
(3.2, 2.8),
|
||||
(4, 3),
|
||||
(4.8, 3.5),
|
||||
(5.6, 4.3),
|
||||
(6.4, 5),
|
||||
(7.2, 7),
|
||||
(8, 8.3),
|
||||
];
|
||||
const Map<String, double> _fixedMet = {
|
||||
'strength': 5,
|
||||
'run': 8,
|
||||
'walk': 3.5,
|
||||
'cycle': 7.5,
|
||||
'other': 4,
|
||||
};
|
||||
const Map<String, double> _acsmSlope = {'run': 0.2, 'walk': 0.1};
|
||||
const Map<String, double> _netKcalPerKgKm = {'run': 0.9, 'walk': 0.5};
|
||||
const double _kcalPerLitreO2 = 5;
|
||||
|
||||
/// Net kcal of one session per formula; [fallback] names MET stand-ins.
|
||||
class SessionKcal {
|
||||
/// Creates a result.
|
||||
const SessionKcal(this.session, this.kcal, this.fallback);
|
||||
|
||||
/// The session measured.
|
||||
final Session session;
|
||||
|
||||
/// Formula name -> net kcal.
|
||||
final Map<String, double> kcal;
|
||||
|
||||
/// Formulas that could not apply and reuse the MET figure.
|
||||
final Set<String> fallback;
|
||||
}
|
||||
|
||||
double _interpolate(List<(double, double)> table, double kmh) {
|
||||
if (kmh <= table.first.$1) return table.first.$2;
|
||||
for (var i = 0; i + 1 < table.length; i++) {
|
||||
final (x0, y0) = table[i];
|
||||
final (x1, y1) = table[i + 1];
|
||||
if (kmh <= x1) return y0 + (y1 - y0) * (kmh - x0) / (x1 - x0);
|
||||
}
|
||||
return table.last.$2;
|
||||
}
|
||||
|
||||
double? _speedKmh(Session session) {
|
||||
final km = session.km;
|
||||
if (km == null || km <= 0 || session.minutes <= 0) return null;
|
||||
return km / (session.minutes / 60.0);
|
||||
}
|
||||
|
||||
/// The MET value for [session]: speed-based when possible, else fixed.
|
||||
double metFor(Session session) {
|
||||
final kmh = _speedKmh(session);
|
||||
if (kmh != null && session.kind == 'run') {
|
||||
return _interpolate(_runMetByKmh, kmh);
|
||||
}
|
||||
if (kmh != null && session.kind == 'walk') {
|
||||
return _interpolate(_walkMetByKmh, kmh);
|
||||
}
|
||||
return _fixedMet[session.kind] ?? _fixedMet['other']!;
|
||||
}
|
||||
|
||||
/// Net kcal of [session] for a [weightKg] person, by every formula.
|
||||
SessionKcal sessionKcal(Session session, double weightKg) {
|
||||
final hours = session.minutes / 60.0;
|
||||
final met = (metFor(session) - 1.0) * weightKg * hours;
|
||||
final kcal = {'MET': met, 'ACSM': met, 'Per-km': met};
|
||||
final fallback = {'ACSM', 'Per-km'};
|
||||
final kmh = _speedKmh(session);
|
||||
final km = session.km;
|
||||
final slope = _acsmSlope[session.kind];
|
||||
if (kmh != null && km != null && slope != null) {
|
||||
final metresPerMin = kmh * 1000.0 / 60.0;
|
||||
final netVo2 = slope * metresPerMin;
|
||||
kcal['ACSM'] = netVo2 * weightKg * session.minutes * _kcalPerLitreO2 / 1000;
|
||||
kcal['Per-km'] = _netKcalPerKgKm[session.kind]! * weightKg * km;
|
||||
fallback.clear();
|
||||
}
|
||||
return SessionKcal(session, kcal, fallback);
|
||||
}
|
||||
|
||||
String _dateKey(DateTime day) {
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${day.year.toString().padLeft(4, '0')}-${two(day.month)}'
|
||||
'-${two(day.day)}';
|
||||
}
|
||||
|
||||
DateTime _utcDay(DateTime day) => DateTime.utc(day.year, day.month, day.day);
|
||||
|
||||
/// How many full past days [dailyExercise] averages over.
|
||||
///
|
||||
/// [maxWindowDays], or fewer while the earliest past data (a session or an
|
||||
/// [extraByDay] key before [today]) is younger than that; 0 when there is no
|
||||
/// past data at all.
|
||||
int windowDays(
|
||||
List<Session> sessions,
|
||||
DateTime today, [
|
||||
Map<String, double>? extraByDay,
|
||||
]) {
|
||||
final last = _dateKey(today);
|
||||
final days = [
|
||||
for (final session in sessions)
|
||||
if (session.day.compareTo(last) < 0) session.day,
|
||||
for (final day in (extraByDay ?? const <String, double>{}).keys)
|
||||
if (day.compareTo(last) < 0) day,
|
||||
];
|
||||
if (days.isEmpty) return 0;
|
||||
final earliest = days.reduce((a, b) => a.compareTo(b) <= 0 ? a : b);
|
||||
// Whole UTC days, so a DST change cannot shave an hour off the count.
|
||||
final span = _utcDay(
|
||||
today,
|
||||
).difference(DateTime.parse('${earliest}T00:00:00Z')).inDays;
|
||||
return span < maxWindowDays ? span : maxWindowDays;
|
||||
}
|
||||
|
||||
/// Mean net exercise kcal per day over the full past days before [today].
|
||||
///
|
||||
/// The window ends yesterday (today is still in progress) and starts
|
||||
/// [maxWindowDays] back -- or, while the data is younger than that, on the
|
||||
/// first day that has any, so a week of history is averaged over that week
|
||||
/// rather than diluted over 14 days ([windowDays]). The denominator is
|
||||
/// calendar days within that span: a rest day burns no exercise kcal. With
|
||||
/// no past data at all, every formula reads 0.
|
||||
///
|
||||
/// [extraByDay] (day -> net kcal, e.g. steps outside workouts) is added to
|
||||
/// every formula alike: it is not a workout any formula could measure.
|
||||
Map<String, double> dailyExercise(
|
||||
List<Session> sessions,
|
||||
double weightKg,
|
||||
DateTime today, [
|
||||
Map<String, double>? extraByDay,
|
||||
]) {
|
||||
final totals = {for (final name in activityFormulas) name: 0.0};
|
||||
final span = windowDays(sessions, today, extraByDay);
|
||||
if (span == 0) return totals;
|
||||
// Calendar arithmetic via the constructor, not Duration: subtracting N x
|
||||
// 24 h across a DST change lands on 23:00 of the wrong day.
|
||||
final first = _dateKey(DateTime(today.year, today.month, today.day - span));
|
||||
final last = _dateKey(today);
|
||||
bool inWindow(String day) =>
|
||||
day.compareTo(first) >= 0 && day.compareTo(last) < 0;
|
||||
for (final session in sessions) {
|
||||
if (!inWindow(session.day)) continue;
|
||||
sessionKcal(session, weightKg).kcal.forEach((name, value) {
|
||||
totals[name] = totals[name]! + value;
|
||||
});
|
||||
}
|
||||
extraByDay?.forEach((day, kcal) {
|
||||
if (!inWindow(day)) return;
|
||||
for (final name in activityFormulas) {
|
||||
totals[name] = totals[name]! + kcal;
|
||||
}
|
||||
});
|
||||
return totals.map((name, total) => MapEntry(name, total / span));
|
||||
}
|
||||
|
||||
/// The part of every [dailyExercise] mean that [extraByDay] contributes,
|
||||
/// over the very same window -- so a "steps add N kcal/day (included above)"
|
||||
/// line always adds up to the total it claims to be part of. Mirrors the
|
||||
/// steps share computed in `diet_guard/_body_view.py`.
|
||||
double extraShare(
|
||||
List<Session> sessions,
|
||||
DateTime today,
|
||||
Map<String, double> extraByDay,
|
||||
) {
|
||||
final span = windowDays(sessions, today, extraByDay);
|
||||
if (span == 0) return 0;
|
||||
final first = _dateKey(DateTime(today.year, today.month, today.day - span));
|
||||
final last = _dateKey(today);
|
||||
var sum = 0.0;
|
||||
extraByDay.forEach((day, kcal) {
|
||||
if (day.compareTo(first) >= 0 && day.compareTo(last) < 0) sum += kcal;
|
||||
});
|
||||
return sum / span;
|
||||
}
|
||||
@ -7,6 +7,7 @@ library;
|
||||
|
||||
import 'dart:developer';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
@ -62,6 +63,9 @@ Future<bool> backgroundSyncPush({http.Client? httpClient}) async {
|
||||
// neither publish this device's catering credential nor receive a peer's --
|
||||
// and on the phone the background tick is most of the syncing there is.
|
||||
await KuchniaCredentialService.init();
|
||||
// `syncBody` no-ops without it, and the background tick is when the
|
||||
// phone ingests the morning weigh-in.
|
||||
await BodyService.init();
|
||||
final SyncSettings settings;
|
||||
try {
|
||||
settings = await SyncSettings.load();
|
||||
|
||||
238
app/lib/services/body_calc.dart
Normal file
238
app/lib/services/body_calc.dart
Normal file
@ -0,0 +1,238 @@
|
||||
/// BMI, ideal-weight and resting-metabolism formulas -- pure arithmetic.
|
||||
///
|
||||
/// Byte-for-byte port of `diet_guard/_body_calc.py`, gated by the shared
|
||||
/// `tests/fixtures/body_calc.json` (`test/services/body_parity_test.dart`).
|
||||
/// Rounding goes through [halfUp], never `.round()`: Dart rounds half away
|
||||
/// from zero and Python's `round` is banker's, so the bare builtins would
|
||||
/// print different numbers on the two devices for the same person.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
const double _cmPerInch = 2.54;
|
||||
const double _fiveFeetIn = 60;
|
||||
|
||||
/// Adult BMI categories are WHO's and do not apply to anyone still growing.
|
||||
const int adultAge = 20;
|
||||
const double _bmiPrimeDivisor = 25;
|
||||
const double _healthyBmiLow = 18.5;
|
||||
const double _healthyBmiHigh = 24.9;
|
||||
const double _targetBmi = 22;
|
||||
|
||||
// (upper bound exclusive, label), WHO adult classification.
|
||||
const List<(double, String)> _bmiBands = [
|
||||
(16, 'severe thinness'),
|
||||
(17, 'moderate thinness'),
|
||||
(18.5, 'mild thinness'),
|
||||
(25, 'normal'),
|
||||
(30, 'overweight'),
|
||||
(35, 'obese class I'),
|
||||
(40, 'obese class II'),
|
||||
];
|
||||
const String _topBand = 'obese class III';
|
||||
|
||||
/// BMI Prime explained, shown under the numbers on both surfaces.
|
||||
const String bmiPrimeNote =
|
||||
'BMI Prime = BMI / 25: 1.00 is the top of the healthy range, '
|
||||
'so 1.02 means 2% above it.';
|
||||
|
||||
/// Trefethen's BMI explained, shown under the numbers on both surfaces.
|
||||
const String trefethenNote =
|
||||
"Trefethen's new BMI = 1.3 x kg / m^2.5: corrects classic BMI, which "
|
||||
'over-reads tall people and under-reads short ones.';
|
||||
|
||||
/// Broca explained, shown under the ideal-weight list.
|
||||
const String brocaNote =
|
||||
'Broca = height in cm - 100 (1871); it overshoots, so Broca (modified) '
|
||||
'takes 10% off it for men and 15% for women.';
|
||||
|
||||
/// The lean-mass formula the calorie table switches to with a body fat %.
|
||||
const String katchMcArdle = 'Katch-McArdle';
|
||||
|
||||
/// Rounds [value] half-up at [digits] decimals, identically to Python.
|
||||
double halfUp(double value, [int digits = 0]) {
|
||||
final scale = math.pow(10, digits).toDouble();
|
||||
return (value * scale + 0.5).floorToDouble() / scale;
|
||||
}
|
||||
|
||||
/// Body metrics that feed every formula.
|
||||
///
|
||||
/// Mirrors Python's `_budget_biometrics.Biometrics`.
|
||||
class Biometrics {
|
||||
/// Creates a set of body metrics.
|
||||
const Biometrics({
|
||||
required this.weightKg,
|
||||
required this.heightCm,
|
||||
required this.ageYears,
|
||||
required this.isMale,
|
||||
});
|
||||
|
||||
/// Body mass in kilograms.
|
||||
final double weightKg;
|
||||
|
||||
/// Height in centimetres.
|
||||
final double heightCm;
|
||||
|
||||
/// Age in completed years.
|
||||
final double ageYears;
|
||||
|
||||
/// True for the male constants.
|
||||
final bool isMale;
|
||||
}
|
||||
|
||||
/// One named formula's result; [value] is null when it does not apply.
|
||||
class Formula {
|
||||
/// Creates a formula result.
|
||||
const Formula(this.name, this.value);
|
||||
|
||||
/// Display name.
|
||||
final String name;
|
||||
|
||||
/// The result, or null when undefined for this person.
|
||||
final double? value;
|
||||
}
|
||||
|
||||
/// Body-mass index and its readings.
|
||||
class BmiReport {
|
||||
/// Creates a report.
|
||||
const BmiReport({
|
||||
required this.bmi,
|
||||
required this.prime,
|
||||
required this.trefethen,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
/// Classic BMI, kg / m^2.
|
||||
final double bmi;
|
||||
|
||||
/// BMI divided by 25.
|
||||
final double prime;
|
||||
|
||||
/// Trefethen's "new BMI", 1.3 * kg / m^2.5.
|
||||
final double trefethen;
|
||||
|
||||
/// WHO adult band, or a note that it does not apply (< 20 y).
|
||||
final String category;
|
||||
}
|
||||
|
||||
/// Mifflin-St Jeor resting metabolic rate, kcal/day.
|
||||
double mifflinStJeorBmr(Biometrics bio) {
|
||||
final base = 10.0 * bio.weightKg + 6.25 * bio.heightCm - 5.0 * bio.ageYears;
|
||||
return bio.isMale ? base + 5.0 : base - 161.0;
|
||||
}
|
||||
|
||||
String _category(double bmi, double ageYears) {
|
||||
if (ageYears < adultAge) return 'adult categories do not apply under 20';
|
||||
for (final (upper, label) in _bmiBands) {
|
||||
if (bmi < upper) return label;
|
||||
}
|
||||
return _topBand;
|
||||
}
|
||||
|
||||
/// Returns BMI, BMI Prime, Trefethen's BMI and the WHO category.
|
||||
BmiReport bmiReport(Biometrics bio) {
|
||||
final metres = bio.heightCm / 100.0;
|
||||
final bmi = bio.weightKg / (metres * metres);
|
||||
return BmiReport(
|
||||
bmi: bmi,
|
||||
prime: bmi / _bmiPrimeDivisor,
|
||||
trefethen: 1.3 * bio.weightKg / math.pow(metres, 2.5),
|
||||
category: _category(bmi, bio.ageYears),
|
||||
);
|
||||
}
|
||||
|
||||
double? _perInch(
|
||||
double inches,
|
||||
double base,
|
||||
double perInch, {
|
||||
required bool defined,
|
||||
}) => defined ? base + perInch * inches : null;
|
||||
|
||||
/// Every ideal-body-weight formula we can evaluate, in kg.
|
||||
///
|
||||
/// Only Creff uses age. The BMI healthy range is [healthyRange], shown with
|
||||
/// BMI; [idealSummary] averages these.
|
||||
List<Formula> idealWeights(Biometrics bio) {
|
||||
final h = bio.heightCm;
|
||||
final inches = h / _cmPerInch - _fiveFeetIn;
|
||||
final ok = inches >= 0;
|
||||
final male = bio.isMale;
|
||||
final metres = h / 100.0;
|
||||
return [
|
||||
Formula('Devine', _perInch(inches, male ? 50 : 45.5, 2.3, defined: ok)),
|
||||
Formula(
|
||||
'Robinson',
|
||||
_perInch(inches, male ? 52 : 49, male ? 1.9 : 1.7, defined: ok),
|
||||
),
|
||||
Formula(
|
||||
'Miller',
|
||||
_perInch(inches, male ? 56.2 : 53.1, male ? 1.41 : 1.36, defined: ok),
|
||||
),
|
||||
Formula(
|
||||
'Hamwi',
|
||||
_perInch(inches, male ? 48 : 45.5, male ? 2.7 : 2.2, defined: ok),
|
||||
),
|
||||
Formula('Broca', h - 100.0),
|
||||
Formula('Broca (modified)', (h - 100.0) * (male ? 0.9 : 0.85)),
|
||||
Formula('Lorentz', h - 100.0 - (h - 150.0) / (male ? 4.0 : 2.0)),
|
||||
Formula('Creff (age)', (h - 100.0 + bio.ageYears / 10.0) * 0.9),
|
||||
Formula(
|
||||
'Peterson (BMI 22)',
|
||||
2.2 * _targetBmi + 3.5 * _targetBmi * (metres - 1.5),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The weights (kg) at BMI 18.5 and 24.9 for this height.
|
||||
(double, double) healthyRange(Biometrics bio) {
|
||||
final metres = bio.heightCm / 100.0;
|
||||
return (_healthyBmiLow * metres * metres, _healthyBmiHigh * metres * metres);
|
||||
}
|
||||
|
||||
/// `(average, lowest, highest)` of the formulas that apply, or null.
|
||||
(double, double, double)? idealSummary(List<Formula> formulas) {
|
||||
final values = [for (final f in formulas) ?f.value];
|
||||
if (values.isEmpty) return null;
|
||||
final sum = values.fold<double>(0, (a, b) => a + b);
|
||||
return (
|
||||
sum / values.length,
|
||||
values.reduce(math.min),
|
||||
values.reduce(math.max),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resting metabolic rate by every formula, kcal/day.
|
||||
///
|
||||
/// Katch-McArdle and Cunningham work from lean mass, so they are null until
|
||||
/// a body-fat % is logged.
|
||||
List<Formula> bmrFormulas(Biometrics bio, [double? bodyFatPct]) {
|
||||
final w = bio.weightKg;
|
||||
final h = bio.heightCm;
|
||||
final a = bio.ageYears;
|
||||
final revised = bio.isMale
|
||||
? 88.362 + 13.397 * w + 4.799 * h - 5.677 * a
|
||||
: 447.593 + 9.247 * w + 3.098 * h - 4.330 * a;
|
||||
final lean = bodyFatPct == null ? null : w * (1.0 - bodyFatPct / 100.0);
|
||||
return [
|
||||
Formula('Mifflin-St Jeor', mifflinStJeorBmr(bio)),
|
||||
Formula('Harris-Benedict (revised 1984)', revised),
|
||||
Formula(katchMcArdle, lean == null ? null : 370.0 + 21.6 * lean),
|
||||
Formula('Cunningham', lean == null ? null : 500.0 + 22.0 * lean),
|
||||
];
|
||||
}
|
||||
|
||||
/// The BMR the calorie table uses: Katch-McArdle with body fat, else Mifflin.
|
||||
Formula tableBmr(Biometrics bio, double? bodyFatPct) {
|
||||
final formulas = bmrFormulas(bio, bodyFatPct);
|
||||
return bodyFatPct != null ? formulas[2] : formulas[0];
|
||||
}
|
||||
|
||||
/// Completed years between [birth] and [today].
|
||||
int ageOn(DateTime birth, DateTime today) {
|
||||
var years = today.year - birth.year;
|
||||
if (today.month < birth.month ||
|
||||
(today.month == birth.month && today.day < birth.day)) {
|
||||
years -= 1;
|
||||
}
|
||||
return years;
|
||||
}
|
||||
206
app/lib/services/body_energy.dart
Normal file
206
app/lib/services/body_energy.dart
Normal file
@ -0,0 +1,206 @@
|
||||
/// The daily calorie target for one chosen goal -- the Body screen's top
|
||||
/// line: "To **lose** **0.5** **kg** / **week** on **Moderate** eat
|
||||
/// **2019** kcal". Every bold word is a [Goal] choice, remembered and synced
|
||||
/// (`body_prefs.dart`). **Informational only**: nothing here writes the
|
||||
/// budget.
|
||||
///
|
||||
/// Port of `diet_guard/_body_energy.py`, gated by the shared
|
||||
/// `tests/fixtures/body_calc.json`. TDEE is the table BMR x an activity
|
||||
/// factor, or for a "Measured" choice `BMR x 1.2` plus the real mean daily
|
||||
/// exercise by that formula. Target = `TDEE -/+ kg per day x 7700`; below
|
||||
/// the 1200 kcal floor it is flagged, never clamped.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
|
||||
/// kcal per kg of body fat.
|
||||
const double kcalPerKg = 7700;
|
||||
|
||||
/// The floor below which a target is flagged.
|
||||
const int minTargetKcal = 1200;
|
||||
|
||||
const double _sedentary = 1.2;
|
||||
|
||||
/// A "Measured" choice is `measured:<formula>` (MET, ACSM, Per-km).
|
||||
const String measuredPrefix = 'measured:';
|
||||
|
||||
/// Goal directions, in menu order.
|
||||
const List<String> directions = ['lose', 'maintain', 'gain'];
|
||||
|
||||
/// Offered in the amount picker; any other typed number is accepted too.
|
||||
const List<double> presetAmounts = [0.25, 0.5, 0.75, 1];
|
||||
|
||||
/// Kilograms per weight unit.
|
||||
const Map<String, double> weightUnits = {
|
||||
'kg': 1,
|
||||
'lb': 0.45359237,
|
||||
'g': 0.001,
|
||||
'st': 6.35029318,
|
||||
};
|
||||
|
||||
/// Days per time unit; a month is the mean Gregorian month.
|
||||
const Map<String, double> timeUnits = {
|
||||
'day': 1,
|
||||
'week': 7,
|
||||
'month': 30.436875,
|
||||
'year': 365.2425,
|
||||
};
|
||||
|
||||
/// One selectable activity level and its TDEE multiplier.
|
||||
class ActivityLevel {
|
||||
/// Creates a level.
|
||||
const ActivityLevel(this.key, this.label, this.factor, this.meaning);
|
||||
|
||||
/// Stable key.
|
||||
final String key;
|
||||
|
||||
/// Display label.
|
||||
final String label;
|
||||
|
||||
/// BMR multiplier.
|
||||
final double factor;
|
||||
|
||||
/// What the level means, shown in the picker.
|
||||
final String meaning;
|
||||
}
|
||||
|
||||
/// Every activity level, in display order.
|
||||
const List<ActivityLevel> activityLevels = [
|
||||
ActivityLevel('sedentary', 'Sedentary', 1.2, 'little or no exercise'),
|
||||
ActivityLevel('light', 'Light', 1.375, 'exercise 1-3x a week'),
|
||||
ActivityLevel('moderate', 'Moderate', 1.55, 'exercise 3-5x a week'),
|
||||
ActivityLevel('very', 'Very active', 1.725, 'exercise 6-7x a week'),
|
||||
ActivityLevel('extra', 'Extra active', 1.9, 'training twice a day'),
|
||||
];
|
||||
|
||||
/// Every activity choice, in menu order: the five levels, then measured.
|
||||
final List<String> activityChoices = [
|
||||
for (final level in activityLevels) level.key,
|
||||
for (final name in activityFormulas) '$measuredPrefix$name',
|
||||
];
|
||||
|
||||
/// What the user wants: direction, amount per time, at an activity.
|
||||
class Goal {
|
||||
/// Creates a goal.
|
||||
const Goal(
|
||||
this.direction,
|
||||
this.amount,
|
||||
this.weightUnit,
|
||||
this.timeUnit,
|
||||
this.activity,
|
||||
);
|
||||
|
||||
/// `lose`, `maintain` or `gain`.
|
||||
final String direction;
|
||||
|
||||
/// How much, in [weightUnit] per [timeUnit].
|
||||
final double amount;
|
||||
|
||||
/// A [weightUnits] key.
|
||||
final String weightUnit;
|
||||
|
||||
/// A [timeUnits] key.
|
||||
final String timeUnit;
|
||||
|
||||
/// An [activityChoices] entry.
|
||||
final String activity;
|
||||
|
||||
/// Returns a copy with the given parts replaced.
|
||||
Goal copyWith({
|
||||
String? direction,
|
||||
double? amount,
|
||||
String? weightUnit,
|
||||
String? timeUnit,
|
||||
String? activity,
|
||||
}) => Goal(
|
||||
direction ?? this.direction,
|
||||
amount ?? this.amount,
|
||||
weightUnit ?? this.weightUnit,
|
||||
timeUnit ?? this.timeUnit,
|
||||
activity ?? this.activity,
|
||||
);
|
||||
|
||||
/// Signed change in kg/day: positive loses, negative gains.
|
||||
double kgPerDay() {
|
||||
if (direction == 'maintain') return 0;
|
||||
final rate = amount * weightUnits[weightUnit]! / timeUnits[timeUnit]!;
|
||||
return direction == 'lose' ? rate : -rate;
|
||||
}
|
||||
}
|
||||
|
||||
/// The goal until the user picks one.
|
||||
const Goal defaultGoal = Goal('lose', 0.5, 'kg', 'week', 'moderate');
|
||||
|
||||
/// The answer to a [Goal], or why there is none.
|
||||
class Target {
|
||||
/// Creates a target.
|
||||
const Target({required this.kcal, required this.tdee, required this.bmrName});
|
||||
|
||||
/// Daily target, or null when the goal needs unpublished measured data.
|
||||
final int? kcal;
|
||||
|
||||
/// The maintenance figure it was derived from, when known.
|
||||
final double? tdee;
|
||||
|
||||
/// The resting-burn formula the TDEE scales.
|
||||
final String bmrName;
|
||||
|
||||
/// Whether the target is under the 1200 kcal floor.
|
||||
bool get belowFloor {
|
||||
final value = kcal;
|
||||
return value != null && value < minTargetKcal;
|
||||
}
|
||||
}
|
||||
|
||||
/// Python's `f"{x:g}"`: six significant digits, trailing zeros dropped.
|
||||
String formatG(double value) {
|
||||
if (value == value.truncateToDouble() && value.abs() < 1e15) {
|
||||
return value.toInt().toString();
|
||||
}
|
||||
var text = value.toStringAsPrecision(6);
|
||||
if (text.contains('.') && !text.contains('e')) {
|
||||
text = text
|
||||
.replaceFirst(RegExp(r'0+$'), '')
|
||||
.replaceFirst(RegExp(r'\.$'), '');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Menu text for an activity choice, e.g. `Measured (ACSM)`.
|
||||
String activityLabel(String key) {
|
||||
if (key.startsWith(measuredPrefix)) {
|
||||
return 'Measured (${key.substring(measuredPrefix.length)})';
|
||||
}
|
||||
for (final level in activityLevels) {
|
||||
if (level.key == key) return level.label;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
double? _tdee(double bmr, String activity, Map<String, double>? exercise) {
|
||||
if (activity.startsWith(measuredPrefix)) {
|
||||
final kcal = exercise?[activity.substring(measuredPrefix.length)];
|
||||
return kcal == null ? null : bmr * _sedentary + kcal;
|
||||
}
|
||||
for (final level in activityLevels) {
|
||||
if (level.key == activity) return bmr * level.factor;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The daily kcal that meets [goal] from a [bmr] (kcal/day).
|
||||
Target goalTarget(
|
||||
Goal goal,
|
||||
double bmr,
|
||||
String bmrName,
|
||||
Map<String, double>? exercise,
|
||||
) {
|
||||
final tdee = _tdee(bmr, goal.activity, exercise);
|
||||
if (tdee == null) return Target(kcal: null, tdee: null, bmrName: bmrName);
|
||||
return Target(
|
||||
kcal: halfUp(tdee - goal.kgPerDay() * kcalPerKg).toInt(),
|
||||
tdee: tdee,
|
||||
bmrName: bmrName,
|
||||
);
|
||||
}
|
||||
49
app/lib/services/body_prefs.dart
Normal file
49
app/lib/services/body_prefs.dart
Normal file
@ -0,0 +1,49 @@
|
||||
/// The calorie goal the user last picked -- remembered and synced.
|
||||
///
|
||||
/// Stored as one extra field, `goal`, on the synced `profile` record (its
|
||||
/// own stamp: saving it never counts as editing birth, height or sex), so the
|
||||
/// sentence "To lose 0.5 kg / week on Moderate eat ..." reads the same on
|
||||
/// the PC gate and the phone. Each part is validated on read: an unknown
|
||||
/// direction, unit or activity falls back to that part of [defaultGoal].
|
||||
///
|
||||
/// Port of `diet_guard/_body_prefs.py`.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
|
||||
/// The profile field the goal is stored under.
|
||||
const String goalField = 'goal';
|
||||
|
||||
/// The wire/disk form (short keys).
|
||||
Map<String, Object> goalToJson(Goal goal) => {
|
||||
'dir': goal.direction,
|
||||
'amt': goal.amount,
|
||||
'wu': goal.weightUnit,
|
||||
'tu': goal.timeUnit,
|
||||
'act': goal.activity,
|
||||
};
|
||||
|
||||
/// Parses a stored goal part by part, defaulting whatever is unusable.
|
||||
Goal goalFromJson(Object? raw) {
|
||||
if (raw is! Map) return defaultGoal;
|
||||
final dir = raw['dir'];
|
||||
final amt = raw['amt'];
|
||||
final wu = raw['wu'];
|
||||
final tu = raw['tu'];
|
||||
final act = raw['act'];
|
||||
return defaultGoal.copyWith(
|
||||
direction: dir is String && directions.contains(dir) ? dir : null,
|
||||
amount: amt is num && amt >= 0 ? amt.toDouble() : null,
|
||||
weightUnit: wu is String && weightUnits.containsKey(wu) ? wu : null,
|
||||
timeUnit: tu is String && timeUnits.containsKey(tu) ? tu : null,
|
||||
activity: act is String && activityChoices.contains(act) ? act : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// The remembered goal (the default until one is saved).
|
||||
Goal savedGoal(BodyService body) => goalFromJson(body.profileValue(goalField));
|
||||
|
||||
/// Remembers (and syncs) [goal].
|
||||
Future<void> setGoal(BodyService body, Goal goal) =>
|
||||
body.setProfileFields({goalField: goalToJson(goal)});
|
||||
225
app/lib/services/body_service.dart
Normal file
225
app/lib/services/body_service.dart
Normal file
@ -0,0 +1,225 @@
|
||||
/// Persistence for the Body document: profile, weight log, PC workouts.
|
||||
///
|
||||
/// A singleton over the platform [DocumentStore], shaped like
|
||||
/// `MealScheduleService`. Every setter stamps the edited part with "now", so
|
||||
/// a manual edit outranks an ingested phone weigh-in of the same day in the
|
||||
/// last-writer-wins merge (`sync_merge_body.dart`). Nothing here touches the
|
||||
/// budget -- the Body screen is informational only.
|
||||
///
|
||||
/// Mirrors the storage half of `diet_guard/_body_store.py`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/services/document_store.dart';
|
||||
import 'package:diet_guard_app/services/document_store_factory.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Singleton owning the `body.json` document.
|
||||
class BodyService {
|
||||
BodyService._(this._store);
|
||||
|
||||
/// Document name this service owns.
|
||||
static const documentName = 'body.json';
|
||||
|
||||
static BodyService? _instance;
|
||||
|
||||
/// Returns the initialized singleton; throws if [init] was not called.
|
||||
static BodyService get instance => _instance!;
|
||||
|
||||
/// True once [init] (or a test initialiser) has run.
|
||||
static bool get isInitialized => _instance != null;
|
||||
|
||||
final DocumentStore _store;
|
||||
BodyDocument _doc = const BodyDocument();
|
||||
|
||||
/// The whole document, for the sync layer.
|
||||
BodyDocument get document => _doc;
|
||||
|
||||
/// Initialises the singleton against the platform document store.
|
||||
static Future<BodyService> init() async {
|
||||
if (_instance != null) return _instance!;
|
||||
// coverage:ignore-start
|
||||
final svc = BodyService._(await openDocumentStore());
|
||||
// coverage:ignore-end
|
||||
await svc._load();
|
||||
_instance = svc;
|
||||
return svc;
|
||||
}
|
||||
|
||||
/// Initialises from [store], for tests.
|
||||
@visibleForTesting
|
||||
static Future<BodyService> initForTesting(DocumentStore store) async {
|
||||
final svc = BodyService._(store);
|
||||
await svc._load();
|
||||
_instance = svc;
|
||||
return svc;
|
||||
}
|
||||
|
||||
/// Drops the singleton, or -- given a [store] -- seeds an empty one over
|
||||
/// it so a later [init] reuses it instead of reaching the platform store.
|
||||
@visibleForTesting
|
||||
static void resetForTesting({DocumentStore? store}) {
|
||||
_instance = store == null ? null : BodyService._(store);
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final raw = await _store.read(documentName);
|
||||
if (raw == null) return;
|
||||
try {
|
||||
_doc = BodyDocument.fromJson(jsonDecode(raw));
|
||||
} on FormatException {
|
||||
// A corrupt document reads as empty, like the other services.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _write(BodyDocument doc) async {
|
||||
_doc = doc;
|
||||
await _store.write(documentName, jsonEncode(doc.toJson()));
|
||||
}
|
||||
|
||||
/// Birth date as `YYYY-MM-DD`, or null.
|
||||
String? get birth => _string(profileBirth);
|
||||
|
||||
/// Height in cm, or null.
|
||||
double? get heightCm {
|
||||
final value = _doc.profile[profileHeight]?.value;
|
||||
return value is num ? value.toDouble() : null;
|
||||
}
|
||||
|
||||
/// `m` / `f`, or null.
|
||||
String? get sex {
|
||||
final value = _string(profileSex);
|
||||
return value == 'm' || value == 'f' ? value : null;
|
||||
}
|
||||
|
||||
/// Whether birth date, height and sex are all set -- the Body screen then
|
||||
/// moves the profile card to the bottom.
|
||||
bool get profileComplete =>
|
||||
DateTime.tryParse(birth ?? '') != null && heightCm != null && sex != null;
|
||||
|
||||
String? _string(String name) {
|
||||
final value = _doc.profile[name]?.value;
|
||||
return value is String && value.isNotEmpty ? value : null;
|
||||
}
|
||||
|
||||
/// Sets the given profile fields, each stamped now; others are untouched.
|
||||
Future<void> setProfile({
|
||||
String? birth,
|
||||
double? heightCm,
|
||||
String? sex,
|
||||
DateTime? now,
|
||||
}) => setProfileFields({
|
||||
profileBirth: ?birth,
|
||||
profileHeight: ?heightCm,
|
||||
profileSex: ?sex,
|
||||
}, now: now);
|
||||
|
||||
/// Sets raw profile [fields], each stamped now; others keep their stamps.
|
||||
Future<void> setProfileFields(
|
||||
Map<String, Object?> fields, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
if (fields.isEmpty) return;
|
||||
final t = isoLocalSeconds(now ?? DateTime.now());
|
||||
final profile = Map<String, Stamped>.from(_doc.profile);
|
||||
fields.forEach((name, value) => profile[name] = Stamped(value, t));
|
||||
await _write(_doc.copyWith(profile: profile));
|
||||
}
|
||||
|
||||
/// A raw profile value, or null.
|
||||
Object? profileValue(String name) => _doc.profile[name]?.value;
|
||||
|
||||
/// Records [kg] for [day] (`YYYY-MM-DD`), stamped now.
|
||||
Future<void> setWeight(String day, double kg, {DateTime? now}) =>
|
||||
_putWeight(day, kg, now);
|
||||
|
||||
/// Deletes [day]'s weight (a tombstone, so the delete syncs).
|
||||
Future<void> deleteWeight(String day, {DateTime? now}) =>
|
||||
_putWeight(day, null, now);
|
||||
|
||||
Future<void> _putWeight(String day, double? kg, DateTime? now) async {
|
||||
final weights = Map<String, WeightEntry>.from(_doc.weights);
|
||||
weights[day] = WeightEntry(
|
||||
kg: kg,
|
||||
src: 'manual',
|
||||
t: isoLocalSeconds(now ?? DateTime.now()),
|
||||
);
|
||||
await _write(_doc.copyWith(weights: weights));
|
||||
}
|
||||
|
||||
/// Records [pct] body fat for [day], stamped now (one decimal, half-up).
|
||||
Future<void> setBodyFat(String day, double pct, {DateTime? now}) =>
|
||||
_putBodyFat(day, halfUp(pct, 1), now);
|
||||
|
||||
/// Deletes [day]'s body fat (a tombstone, so the delete syncs).
|
||||
Future<void> deleteBodyFat(String day, {DateTime? now}) =>
|
||||
_putBodyFat(day, null, now);
|
||||
|
||||
Future<void> _putBodyFat(String day, double? pct, DateTime? now) async {
|
||||
final bodyFat = Map<String, BodyFatEntry>.from(_doc.bodyFat);
|
||||
bodyFat[day] = BodyFatEntry(
|
||||
pct: pct,
|
||||
t: isoLocalSeconds(now ?? DateTime.now()),
|
||||
);
|
||||
await _write(_doc.copyWith(bodyFat: bodyFat));
|
||||
}
|
||||
|
||||
/// Non-deleted body fat readings, oldest first, as `(YYYY-MM-DD, %)`.
|
||||
List<(String, double)> get bodyFatSeries =>
|
||||
_series(_doc.bodyFat.map((day, entry) => MapEntry(day, entry.pct)));
|
||||
|
||||
/// The newest non-deleted body fat %, or null.
|
||||
(String, double)? get latestBodyFat {
|
||||
final series = bodyFatSeries;
|
||||
return series.isEmpty ? null : series.last;
|
||||
}
|
||||
|
||||
/// Applies ingested weigh-ins: each replaces its day only when the local
|
||||
/// entry is missing or stamped **older**. Returns whether anything changed.
|
||||
Future<bool> applyCandidates(Map<String, WeightEntry> candidates) async {
|
||||
final weights = Map<String, WeightEntry>.from(_doc.weights);
|
||||
var changed = false;
|
||||
candidates.forEach((day, candidate) {
|
||||
final local = weights[day];
|
||||
if (local == null || stampMillis(local.t) < stampMillis(candidate.t)) {
|
||||
weights[day] = candidate;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) await _write(_doc.copyWith(weights: weights));
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// Replaces the document with a sync merge result.
|
||||
Future<void> applyMerged(BodyDocument merged) => _write(merged);
|
||||
|
||||
/// Non-deleted weights, oldest first, as `(YYYY-MM-DD, kg)`.
|
||||
List<(String, double)> get weightSeries =>
|
||||
_series(_doc.weights.map((day, entry) => MapEntry(day, entry.kg)));
|
||||
|
||||
static List<(String, double)> _series(Map<String, double?> byDay) {
|
||||
final days = byDay.keys.toList()..sort();
|
||||
return [
|
||||
for (final day in days)
|
||||
if (byDay[day] case final value?) (day, value),
|
||||
];
|
||||
}
|
||||
|
||||
/// The newest non-deleted weight, or null.
|
||||
(String, double)? get latestWeight {
|
||||
final series = weightSeries;
|
||||
return series.isEmpty ? null : series.last;
|
||||
}
|
||||
|
||||
/// Workouts the PC published, parsed; null when it never published any.
|
||||
List<Session>? get sessions {
|
||||
final activity = _doc.activity;
|
||||
if (activity == null) return null;
|
||||
return [for (final raw in activity.sessions) ?Session.fromWire(raw)];
|
||||
}
|
||||
}
|
||||
30
app/lib/services/body_steps.dart
Normal file
30
app/lib/services/body_steps.dart
Normal file
@ -0,0 +1,30 @@
|
||||
/// Daily steps (from Health Connect, phone-written) as exercise kcal.
|
||||
///
|
||||
/// Only steps above [stepBaseline] count: the `BMR x 1.2` sedentary
|
||||
/// baseline the measured TDEE starts from already assumes a few thousand
|
||||
/// steps of ordinary movement. The rest are walking at the per-km rate
|
||||
/// (0.5 kcal per kg per km, net of resting) over a stride estimated from
|
||||
/// height. Port of `diet_guard/_body_steps.py`.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
|
||||
/// Steps a day the sedentary baseline already covers.
|
||||
const int stepBaseline = 4000;
|
||||
|
||||
// Stride as a fraction of height (the common pedometer estimate).
|
||||
const double _strideMale = 0.415;
|
||||
const double _strideFemale = 0.413;
|
||||
const double _walkKcalPerKgKm = 0.5;
|
||||
const double _mPerKm = 1000;
|
||||
|
||||
/// Estimated walking stride in metres.
|
||||
double strideM(Biometrics bio) =>
|
||||
bio.heightCm / 100.0 * (bio.isMale ? _strideMale : _strideFemale);
|
||||
|
||||
/// Net kcal of the steps above the baseline, as walking.
|
||||
double stepKcal(int steps, Biometrics bio) {
|
||||
final extra = steps > stepBaseline ? steps - stepBaseline : 0;
|
||||
final km = extra * strideM(bio) / _mPerKm;
|
||||
return km * _walkKcalPerKgKm * bio.weightKg;
|
||||
}
|
||||
133
app/lib/services/health_steps.dart
Normal file
133
app/lib/services/health_steps.dart
Normal file
@ -0,0 +1,133 @@
|
||||
/// Daily steps from Android Health Connect -> the synced Body document.
|
||||
///
|
||||
/// The phone is the only writer: it holds the Health Connect permission.
|
||||
/// Each refresh reads the step intervals of the last [stepsWindowDays] days,
|
||||
/// **drops every interval overlapping a published workout's `start..end`**
|
||||
/// (so a recorded walk is not counted as both a workout and steps), sums the
|
||||
/// rest per local day and stores `steps: {day: {n, t}}`, re-stamping a day
|
||||
/// only when its count changed -- so an unchanged day re-syncs as a no-op.
|
||||
///
|
||||
/// Foreground only: reading in the background isolate needs the extra
|
||||
/// `READ_HEALTH_DATA_IN_BACKGROUND` permission, which this app does not ask
|
||||
/// for. And never prompts on its own: [refreshSteps] only reads when the
|
||||
/// permission is already granted; the Body screen's button asks.
|
||||
///
|
||||
/// The platform edge is [StepsSource], picked by the conditional export in
|
||||
/// `health_steps_source.dart` (Health Connect on Android, nothing on web).
|
||||
library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/health_steps_source.dart';
|
||||
|
||||
/// How far back each refresh reads.
|
||||
const int stepsWindowDays = 28;
|
||||
|
||||
/// One Health Connect steps record: [count] steps between [from] and [to].
|
||||
class StepInterval {
|
||||
/// Creates an interval.
|
||||
const StepInterval(this.from, this.to, this.count);
|
||||
|
||||
/// Start of the interval.
|
||||
final DateTime from;
|
||||
|
||||
/// End of the interval.
|
||||
final DateTime to;
|
||||
|
||||
/// Steps taken in it.
|
||||
final int count;
|
||||
}
|
||||
|
||||
/// Where step intervals come from.
|
||||
abstract class StepsSource {
|
||||
/// Whether this platform can read steps at all.
|
||||
bool get supported;
|
||||
|
||||
/// Whether the read permission is already granted (never prompts).
|
||||
Future<bool> hasPermission();
|
||||
|
||||
/// Asks the user for the read permission; true when granted.
|
||||
Future<bool> requestPermission();
|
||||
|
||||
/// Every steps record between [from] and [to].
|
||||
Future<List<StepInterval>> read(DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
/// The source in use; tests replace it.
|
||||
StepsSource stepsSource = createStepsSource();
|
||||
|
||||
/// Steps per local day, leaving out intervals overlapping any workout.
|
||||
///
|
||||
/// Sessions without both `start` and `end` exclude nothing.
|
||||
Map<String, int> stepsOutsideWorkouts(
|
||||
List<StepInterval> intervals,
|
||||
List<Session> sessions,
|
||||
) {
|
||||
final windows = [
|
||||
for (final session in sessions)
|
||||
if (DateTime.tryParse(session.start ?? '') case final start?)
|
||||
if (DateTime.tryParse(session.end ?? '') case final end?) (start, end),
|
||||
];
|
||||
final byDay = <String, int>{};
|
||||
for (final interval in intervals) {
|
||||
final overlaps = windows.any(
|
||||
(window) =>
|
||||
interval.from.isBefore(window.$2) && interval.to.isAfter(window.$1),
|
||||
);
|
||||
if (overlaps) continue;
|
||||
final day = localDateKey(interval.from.toLocal());
|
||||
byDay[day] = (byDay[day] ?? 0) + interval.count;
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
|
||||
/// Stores [counts], re-stamping only the days whose count changed.
|
||||
/// Returns whether anything was written.
|
||||
Future<bool> applySteps(
|
||||
BodyService body,
|
||||
Map<String, int> counts, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
final steps = Map<String, StepsEntry>.from(body.document.steps);
|
||||
final t = isoLocalSeconds(now ?? DateTime.now());
|
||||
var changed = false;
|
||||
counts.forEach((day, n) {
|
||||
if (steps[day]?.n == n) return;
|
||||
steps[day] = StepsEntry(n: n, t: t);
|
||||
changed = true;
|
||||
});
|
||||
if (changed) await body.applyMerged(body.document.copyWith(steps: steps));
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// Reads Health Connect and stores the daily counts, when permitted.
|
||||
///
|
||||
/// Never throws and never prompts: a missing permission, an unsupported
|
||||
/// platform or a Health Connect error just leaves the stored counts alone.
|
||||
Future<bool> refreshSteps({DateTime? now}) async {
|
||||
final source = stepsSource;
|
||||
if (!BodyService.isInitialized || !source.supported) return false;
|
||||
try {
|
||||
if (!await source.hasPermission()) return false;
|
||||
final end = now ?? DateTime.now();
|
||||
final start = DateTime(end.year, end.month, end.day - stepsWindowDays);
|
||||
final body = BodyService.instance;
|
||||
final counts = stepsOutsideWorkouts(
|
||||
await source.read(start, end),
|
||||
body.sessions ?? const [],
|
||||
);
|
||||
return await applySteps(body, counts, now: now);
|
||||
} on Object catch (error, stackTrace) {
|
||||
log(
|
||||
'Health Connect steps refresh failed',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
7
app/lib/services/health_steps_source.dart
Normal file
7
app/lib/services/health_steps_source.dart
Normal file
@ -0,0 +1,7 @@
|
||||
/// Picks the [StepsSource] for this platform: Health Connect where
|
||||
/// `dart:io` exists (Android), nothing on web -- the desktop app is a web
|
||||
/// build and has no Health Connect.
|
||||
library;
|
||||
|
||||
export 'health_steps_source_io.dart'
|
||||
if (dart.library.js_interop) 'health_steps_source_web.dart';
|
||||
65
app/lib/services/health_steps_source_io.dart
Normal file
65
app/lib/services/health_steps_source_io.dart
Normal file
@ -0,0 +1,65 @@
|
||||
/// Native half of `health_steps_source.dart`: Android Health Connect steps.
|
||||
///
|
||||
/// Anything but Android reports unsupported (the Linux `flutter test` VM
|
||||
/// included), so tests never reach the plugin's platform channel.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/services/health_steps.dart';
|
||||
import 'package:health/health.dart';
|
||||
|
||||
const List<HealthDataType> _types = [HealthDataType.STEPS];
|
||||
const List<HealthDataAccess> _access = [HealthDataAccess.READ];
|
||||
|
||||
/// Health Connect on Android, nothing elsewhere.
|
||||
StepsSource createStepsSource() => HealthConnectStepsSource();
|
||||
|
||||
/// Reads `StepsRecord`s through the `health` plugin.
|
||||
class HealthConnectStepsSource implements StepsSource {
|
||||
Health? _health;
|
||||
|
||||
@override
|
||||
bool get supported => Platform.isAndroid;
|
||||
|
||||
// The rest talks to the plugin's platform channel, which `flutter test`
|
||||
// has no binding for -- same reason `firebase_backend.dart` is excluded.
|
||||
// coverage:ignore-start
|
||||
Future<Health> _plugin() async {
|
||||
final existing = _health;
|
||||
if (existing != null) return existing;
|
||||
final health = Health();
|
||||
await health.configure();
|
||||
return _health = health;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasPermission() async =>
|
||||
await (await _plugin()).hasPermissions(_types, permissions: _access) ??
|
||||
false;
|
||||
|
||||
@override
|
||||
Future<bool> requestPermission() async => await (await _plugin())
|
||||
.requestAuthorization(_types, permissions: _access);
|
||||
|
||||
@override
|
||||
Future<List<StepInterval>> read(DateTime from, DateTime to) async {
|
||||
// Already de-duplicated by the plugin (`removeDuplicates`).
|
||||
final points = await (await _plugin()).getHealthDataFromTypes(
|
||||
types: _types,
|
||||
startTime: from,
|
||||
endTime: to,
|
||||
);
|
||||
return [
|
||||
for (final point in points)
|
||||
if (point.value case final NumericHealthValue value)
|
||||
StepInterval(
|
||||
point.dateFrom,
|
||||
point.dateTo,
|
||||
value.numericValue.toInt(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// coverage:ignore-end
|
||||
}
|
||||
25
app/lib/services/health_steps_source_web.dart
Normal file
25
app/lib/services/health_steps_source_web.dart
Normal file
@ -0,0 +1,25 @@
|
||||
/// Web half of `health_steps_source.dart`: no Health Connect in a browser.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/health_steps.dart';
|
||||
|
||||
/// A source that never reads anything.
|
||||
StepsSource createStepsSource() => const NoStepsSource();
|
||||
|
||||
/// Reports "unsupported" and reads nothing.
|
||||
class NoStepsSource implements StepsSource {
|
||||
/// Creates the source.
|
||||
const NoStepsSource();
|
||||
|
||||
@override
|
||||
bool get supported => false;
|
||||
|
||||
@override
|
||||
Future<bool> hasPermission() async => false;
|
||||
|
||||
@override
|
||||
Future<bool> requestPermission() async => false;
|
||||
|
||||
@override
|
||||
Future<List<StepInterval>> read(DateTime from, DateTime to) async => [];
|
||||
}
|
||||
35
app/lib/services/sync_body.dart
Normal file
35
app/lib/services/sync_body.dart
Normal file
@ -0,0 +1,35 @@
|
||||
/// The Body document's part of a sync tick.
|
||||
///
|
||||
/// Its own file because `sync_service.dart` is near the repo's 250-line cap.
|
||||
/// First ingests the wake-alarm weigh-ins (never throws), then pulls peers'
|
||||
/// `body.json`, merges last-writer-wins per field, applies the result locally
|
||||
/// and pushes. Mirrors `diet_guard/_sync_body.py`.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_body.dart';
|
||||
import 'package:diet_guard_app/services/wake_alarm_weights.dart';
|
||||
|
||||
/// Runs the Body sync against [client], under [devicesDir].
|
||||
///
|
||||
/// 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}) async {
|
||||
if (!BodyService.isInitialized) return;
|
||||
await ingestWakeAlarmWeights(client);
|
||||
final merged = await syncLog(
|
||||
client: client,
|
||||
deviceId: currentSyncDeviceId,
|
||||
legacyDeviceId: legacySyncDeviceId,
|
||||
pathPrefix: devicesDir,
|
||||
localLog: bodyToLog(BodyService.instance.document),
|
||||
encode: encodeBodyForPush,
|
||||
decode: parseRemoteBody,
|
||||
filename: 'body.json',
|
||||
commitMessage: 'diet_guard_app sync',
|
||||
);
|
||||
if (merged.isEmpty) return;
|
||||
await BodyService.instance.applyMerged(logToBody(merged));
|
||||
}
|
||||
172
app/lib/services/sync_merge_body.dart
Normal file
172
app/lib/services/sync_merge_body.dart
Normal file
@ -0,0 +1,172 @@
|
||||
/// Body document <-> crdt_sync Record adapters.
|
||||
///
|
||||
/// Wire format (see `docs/DOCS-body.md`), one Log per device-pushed
|
||||
/// `body.json`:
|
||||
///
|
||||
/// * `profile`: fields `birth`, `height_cm`, `sex`, `tbl_act`, `tbl_rates`,
|
||||
/// each with its own clock, so editing height on one device cannot revert
|
||||
/// a birth date edited on the other. Unknown fields are relayed as-is.
|
||||
/// * `w:<YYYY-MM-DD>`: field `kg` = `{"kg": number|null, "src": string}`.
|
||||
/// * `bf:<YYYY-MM-DD>`: field `pct` = `{"pct": number|null}`.
|
||||
/// * `st:<YYYY-MM-DD>`: field `n` = `{"n": int}` (phone-written steps).
|
||||
/// * `activity`: field `sessions` = the PC's published workouts.
|
||||
///
|
||||
/// Every clock's wall time is the part's own `t` stamp -- the same
|
||||
/// determinism trick as `sync_merge_budget.dart`, so re-syncing an unchanged
|
||||
/// document is a no-op. KEEP IN SYNC WITH `diet_guard/sync_merge/_body.py`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
|
||||
/// The profile record's id.
|
||||
const profileRecordId = 'profile';
|
||||
|
||||
/// Weight record id prefix.
|
||||
const weightRecordPrefix = 'w:';
|
||||
|
||||
/// Body-fat record id prefix.
|
||||
const bodyFatRecordPrefix = 'bf:';
|
||||
|
||||
/// Steps record id prefix.
|
||||
const stepsRecordPrefix = 'st:';
|
||||
|
||||
/// The activity record's id.
|
||||
const activityRecordId = 'activity';
|
||||
|
||||
Hlc _hlc(String t) =>
|
||||
Hlc.newTick(currentSyncDeviceId, wallTimeMsOverride: stampMillis(t));
|
||||
|
||||
/// Rebuilds an edit stamp from a merged clock: local ISO, with milliseconds
|
||||
/// only when the clock has them, so a whole-second stamp round-trips
|
||||
/// byte-for-byte and re-derives the same clock.
|
||||
String stampFromHlc(Hlc hlc) {
|
||||
final moment = DateTime.fromMillisecondsSinceEpoch(hlc.wallTimeMs);
|
||||
if (hlc.wallTimeMs % 1000 == 0) return isoLocalSeconds(moment);
|
||||
return moment.toUtc().toIso8601String();
|
||||
}
|
||||
|
||||
/// Converts the local document into a [Log].
|
||||
Log bodyToLog(BodyDocument doc) {
|
||||
final log = <String, Record>{};
|
||||
if (doc.profile.isNotEmpty) {
|
||||
log[profileRecordId] = Record(
|
||||
id: profileRecordId,
|
||||
fields: {
|
||||
for (final entry in doc.profile.entries)
|
||||
entry.key: (entry.value.value, _hlc(entry.value.t)),
|
||||
},
|
||||
);
|
||||
}
|
||||
for (final entry in doc.weights.entries) {
|
||||
final id = '$weightRecordPrefix${entry.key}';
|
||||
log[id] = Record(
|
||||
id: id,
|
||||
fields: {
|
||||
'kg': (
|
||||
{'kg': entry.value.kg, 'src': entry.value.src},
|
||||
_hlc(entry.value.t),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
for (final entry in doc.bodyFat.entries) {
|
||||
final id = '$bodyFatRecordPrefix${entry.key}';
|
||||
log[id] = Record(
|
||||
id: id,
|
||||
fields: {
|
||||
'pct': ({'pct': entry.value.pct}, _hlc(entry.value.t)),
|
||||
},
|
||||
);
|
||||
}
|
||||
for (final entry in doc.steps.entries) {
|
||||
final id = '$stepsRecordPrefix${entry.key}';
|
||||
log[id] = Record(
|
||||
id: id,
|
||||
fields: {
|
||||
'n': ({'n': entry.value.n}, _hlc(entry.value.t)),
|
||||
},
|
||||
);
|
||||
}
|
||||
final activity = doc.activity;
|
||||
if (activity != null) {
|
||||
log[activityRecordId] = Record(
|
||||
id: activityRecordId,
|
||||
fields: {'sessions': (activity.sessions, _hlc(activity.t))},
|
||||
);
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
/// Every profile field, as merged; the service validates on read.
|
||||
Map<String, Stamped> _profile(Record? record) => {
|
||||
if (record != null)
|
||||
for (final entry in record.fields.entries)
|
||||
entry.key: Stamped(entry.value.$1, stampFromHlc(entry.value.$2)),
|
||||
};
|
||||
|
||||
/// Converts a merged [Log] back into a document. Malformed parts are skipped.
|
||||
BodyDocument logToBody(Log log) {
|
||||
final weights = <String, WeightEntry>{};
|
||||
for (final record in log.values) {
|
||||
if (record.deleted || !record.id.startsWith(weightRecordPrefix)) continue;
|
||||
final day = record.id.substring(weightRecordPrefix.length);
|
||||
final field = record.fields['kg'];
|
||||
if (!isDateKey(day) || field == null) continue;
|
||||
final entry = parseWeightValue(field.$1, stampFromHlc(field.$2));
|
||||
if (entry != null) weights[day] = entry;
|
||||
}
|
||||
final bodyFat = <String, BodyFatEntry>{};
|
||||
for (final record in log.values) {
|
||||
if (record.deleted || !record.id.startsWith(bodyFatRecordPrefix)) continue;
|
||||
final day = record.id.substring(bodyFatRecordPrefix.length);
|
||||
final field = record.fields['pct'];
|
||||
if (!isDateKey(day) || field == null) continue;
|
||||
final entry = parseBodyFatValue(field.$1, stampFromHlc(field.$2));
|
||||
if (entry != null) bodyFat[day] = entry;
|
||||
}
|
||||
final steps = <String, StepsEntry>{};
|
||||
for (final record in log.values) {
|
||||
if (record.deleted || !record.id.startsWith(stepsRecordPrefix)) continue;
|
||||
final day = record.id.substring(stepsRecordPrefix.length);
|
||||
final field = record.fields['n'];
|
||||
if (!isDateKey(day) || field == null) continue;
|
||||
final entry = parseStepsValue(field.$1, stampFromHlc(field.$2));
|
||||
if (entry != null) steps[day] = entry;
|
||||
}
|
||||
final activityField = log[activityRecordId]?.fields['sessions'];
|
||||
final sessions = activityField?.$1;
|
||||
return BodyDocument(
|
||||
profile: _profile(log[profileRecordId]),
|
||||
weights: weights,
|
||||
bodyFat: bodyFat,
|
||||
steps: steps,
|
||||
activity: activityField == null || sessions is! List
|
||||
? null
|
||||
: ActivityData(
|
||||
sessions: List<Object?>.from(sessions),
|
||||
t: stampFromHlc(activityField.$2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses one device's pushed `body.json`. Throws [FormatException] or
|
||||
/// [TypeError] on a corrupt push, which `syncLog` treats as unparsable.
|
||||
Log parseRemoteBody(String text) {
|
||||
final raw = jsonDecode(text);
|
||||
if (raw is! Map) {
|
||||
throw const FormatException('top-level body payload is not an object');
|
||||
}
|
||||
return raw.cast<String, dynamic>().map(
|
||||
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Serializes a merged body [Log] for push.
|
||||
String encodeBodyForPush(Log log) => jsonEncode({
|
||||
for (final entry in log.entries) entry.key: entry.value.toJson(),
|
||||
});
|
||||
@ -20,6 +20,7 @@ import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_body.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_kuchnia.dart';
|
||||
@ -62,6 +63,7 @@ Future<DayLog> runSync(RemoteStore client, {SyncStateStore? stateStore}) async {
|
||||
await _syncFoodBank(client);
|
||||
await _syncManualBank(client);
|
||||
await _syncKuchniaCredential(client);
|
||||
await syncBody(client, devicesDir: _devicesDir);
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
111
app/lib/services/wake_alarm_weights.dart
Normal file
111
app/lib/services/wake_alarm_weights.dart
Normal file
@ -0,0 +1,111 @@
|
||||
/// Morning weigh-ins from the wake-alarm app, as Body weight candidates.
|
||||
///
|
||||
/// The wake-alarm phone app publishes
|
||||
/// `wake-alarm-sync/devices/<id>/alarm.json`: a crdt_sync Log whose
|
||||
/// `alarm` record carries `fields[name] = [value, hlc]`. Two fields hold
|
||||
/// weights:
|
||||
///
|
||||
/// * `latest_weight_kg` = `{"date", "kg"}` -- stamped with **that field's**
|
||||
/// clock, so a re-typed weigh-in is newer and wins;
|
||||
/// * `morning_sessions` = recent sessions with `date` and `weight_kg` --
|
||||
/// stamped `<date>T00:00:00+00:00`, so they only fill gaps: the list is
|
||||
/// republished every morning with a new clock, and using that clock would
|
||||
/// overwrite every manual correction daily.
|
||||
///
|
||||
/// Pure: raw JSON in, candidates out; [ingestWakeAlarmWeights] is the impure
|
||||
/// edge. KEEP IN SYNC WITH the PC's ingestion in `diet_guard/_body_ingest.py`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
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';
|
||||
|
||||
const _latestField = 'latest_weight_kg';
|
||||
const _sessionsField = 'morning_sessions';
|
||||
|
||||
List<Object?>? _pair(Map<Object?, Object?> fields, String name) {
|
||||
final pair = fields[name];
|
||||
return pair is List && pair.length >= 2 ? pair : null;
|
||||
}
|
||||
|
||||
void _offer(Map<String, WeightEntry> out, Object? day, Object? kg, String t) {
|
||||
if (!isDateKey(day) || !plausibleKg(kg)) return;
|
||||
final key = day! as String;
|
||||
final current = out[key];
|
||||
if (current != null && stampMillis(current.t) >= stampMillis(t)) return;
|
||||
out[key] = WeightEntry(kg: (kg! as num).toDouble(), src: 'phone', t: t);
|
||||
}
|
||||
|
||||
void _fromLog(Map<String, WeightEntry> out, Object? log) {
|
||||
if (log is! Map) return;
|
||||
final record = log['alarm'];
|
||||
final fields = record is Map ? record['fields'] : null;
|
||||
if (fields is! Map) return;
|
||||
final latest = _pair(fields, _latestField);
|
||||
final value = latest?[0];
|
||||
final hlcText = latest?[1];
|
||||
if (value is Map && hlcText is String) {
|
||||
try {
|
||||
final stamp = stampFromHlc(Hlc.fromStr(hlcText));
|
||||
_offer(out, value['date'], value['kg'], stamp);
|
||||
} on FormatException {
|
||||
// An unreadable clock makes the weigh-in unrankable; skip it.
|
||||
}
|
||||
}
|
||||
final sessions = _pair(fields, _sessionsField)?[0];
|
||||
if (sessions is! List) return;
|
||||
for (final session in sessions) {
|
||||
if (session is! Map) continue;
|
||||
final day = session['date'];
|
||||
_offer(out, day, session['weight_kg'], '${day}T00:00:00+00:00');
|
||||
}
|
||||
}
|
||||
|
||||
/// Weight candidates by day from every device's raw `alarm.json`, newest
|
||||
/// stamp per day. Unparsable devices are skipped.
|
||||
Map<String, WeightEntry> wakeAlarmCandidates(List<(String, String)> published) {
|
||||
final out = <String, WeightEntry>{};
|
||||
for (final (_, raw) in published) {
|
||||
try {
|
||||
_fromLog(out, jsonDecode(raw));
|
||||
} on FormatException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Reads every wake-alarm device's record and applies its weigh-ins.
|
||||
///
|
||||
/// Never throws: a missing or unreadable wake-alarm namespace must not fail
|
||||
/// the diet sync tick it runs inside. Returns whether anything changed.
|
||||
Future<bool> ingestWakeAlarmWeights(RemoteStore client) async {
|
||||
if (!BodyService.isInitialized) return false;
|
||||
try {
|
||||
final published = <(String, String)>[];
|
||||
for (final device in await client.listDirectory(wakeAlarmDevicesDir)) {
|
||||
final text = await client.getFileText(
|
||||
'$wakeAlarmDevicesDir/$device/alarm.json',
|
||||
);
|
||||
if (text != null) published.add((device, text));
|
||||
}
|
||||
return await BodyService.instance.applyCandidates(
|
||||
wakeAlarmCandidates(published),
|
||||
);
|
||||
} on Object catch (error, stackTrace) {
|
||||
log(
|
||||
'wake-alarm weigh-in ingestion failed; continuing the sync',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
128
app/lib/services/week_plan.dart
Normal file
128
app/lib/services/week_plan.dart
Normal file
@ -0,0 +1,128 @@
|
||||
/// Weekly-average planner: what to eat on the rest of the week's days.
|
||||
///
|
||||
/// Port of `diet_guard/_week_plan.py`, gated by the shared
|
||||
/// `tests/fixtures/body_calc.json`. The week is Monday-Sunday around
|
||||
/// `today`. A day is known when the user typed a number for it, or when it is
|
||||
/// already over and has meals in the log; every other day -- today included --
|
||||
/// shares `(target x 7 - known) / remaining`. A past day with no log is
|
||||
/// `unlogged` and planned like a remaining day (an unlogged day is not a
|
||||
/// zero-kcal day). Informational only: it never touches the budget.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
|
||||
/// Weekday labels, Monday first.
|
||||
const List<String> weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
/// One day of the plan.
|
||||
class DayPlan {
|
||||
/// Creates a day.
|
||||
const DayPlan({
|
||||
required this.day,
|
||||
required this.weekday,
|
||||
required this.kcal,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
/// `YYYY-MM-DD`.
|
||||
final String day;
|
||||
|
||||
/// `Mon` .. `Sun`.
|
||||
final String weekday;
|
||||
|
||||
/// What was eaten (known) or should be eaten (planned).
|
||||
final int kcal;
|
||||
|
||||
/// `typed`, `logged`, `plan` or `unlogged`.
|
||||
final String source;
|
||||
}
|
||||
|
||||
/// The whole week.
|
||||
class WeekPlan {
|
||||
/// Creates a plan.
|
||||
const WeekPlan({
|
||||
required this.days,
|
||||
required this.targetAvg,
|
||||
required this.perRemaining,
|
||||
required this.weekAvg,
|
||||
});
|
||||
|
||||
/// Monday .. Sunday.
|
||||
final List<DayPlan> days;
|
||||
|
||||
/// The weekly average aimed for.
|
||||
final int targetAvg;
|
||||
|
||||
/// kcal for each open day, or null when none remain.
|
||||
final int? perRemaining;
|
||||
|
||||
/// The average the plan produces.
|
||||
final double weekAvg;
|
||||
|
||||
/// Whether the remaining days would have to go under 1200 kcal.
|
||||
bool get belowFloor {
|
||||
final per = perRemaining;
|
||||
return per != null && per < minTargetKcal;
|
||||
}
|
||||
}
|
||||
|
||||
String _key(DateTime day) {
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${day.year.toString().padLeft(4, '0')}-${two(day.month)}'
|
||||
'-${two(day.day)}';
|
||||
}
|
||||
|
||||
/// The Monday of [today]'s week, at local midnight.
|
||||
DateTime weekStart(DateTime today) =>
|
||||
DateTime(today.year, today.month, today.day - (today.weekday - 1));
|
||||
|
||||
/// Plans the rest of [today]'s week.
|
||||
///
|
||||
/// [logged] maps `YYYY-MM-DD` to kcal eaten (only days before [today] are
|
||||
/// used); [typed] maps weekday index (0 = Monday) to a typed kcal, which wins
|
||||
/// over the log and may name any day.
|
||||
WeekPlan planWeek(
|
||||
DateTime today,
|
||||
int targetAvg,
|
||||
Map<String, double> logged,
|
||||
Map<int, int> typed,
|
||||
) {
|
||||
final monday = weekStart(today);
|
||||
final todayKey = _key(today);
|
||||
String dayKey(int index) =>
|
||||
_key(DateTime(monday.year, monday.month, monday.day + index));
|
||||
final known = <int, (int, String)>{};
|
||||
for (var index = 0; index < 7; index++) {
|
||||
final key = dayKey(index);
|
||||
final typedKcal = typed[index];
|
||||
final loggedKcal = logged[key];
|
||||
if (typedKcal != null) {
|
||||
known[index] = (typedKcal, 'typed');
|
||||
} else if (key.compareTo(todayKey) < 0 && loggedKcal != null) {
|
||||
known[index] = (halfUp(loggedKcal).toInt(), 'logged');
|
||||
}
|
||||
}
|
||||
final remaining = 7 - known.length;
|
||||
final eaten = known.values.fold<int>(0, (sum, day) => sum + day.$1);
|
||||
final per = remaining == 0
|
||||
? null
|
||||
: halfUp((targetAvg * 7 - eaten) / remaining).toInt();
|
||||
final days = [
|
||||
for (var index = 0; index < 7; index++)
|
||||
DayPlan(
|
||||
day: dayKey(index),
|
||||
weekday: weekdays[index],
|
||||
kcal: known[index]?.$1 ?? per ?? 0,
|
||||
source:
|
||||
known[index]?.$2 ??
|
||||
(dayKey(index).compareTo(todayKey) < 0 ? 'unlogged' : 'plan'),
|
||||
),
|
||||
];
|
||||
return WeekPlan(
|
||||
days: days,
|
||||
targetAvg: targetAvg,
|
||||
perRemaining: per,
|
||||
weekAvg: days.fold<int>(0, (sum, day) => sum + day.kcal) / 7,
|
||||
);
|
||||
}
|
||||
127
app/lib/widgets/body/activity_card.dart
Normal file
127
app/lib/widgets/body/activity_card.dart
Normal file
@ -0,0 +1,127 @@
|
||||
/// Recent workouts the PC published, with exercise kcal per formula.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/body_steps.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// How many of the newest sessions to list.
|
||||
const int activityListLimit = 10;
|
||||
|
||||
/// Shows the 14-day daily means and the newest sessions.
|
||||
class ActivityCard extends StatelessWidget {
|
||||
/// Creates the card.
|
||||
const ActivityCard({
|
||||
required this.sessions,
|
||||
required this.weightKg,
|
||||
required this.exercise,
|
||||
this.steps = const {},
|
||||
this.stepsKcal,
|
||||
this.onConnectSteps,
|
||||
this.window = maxWindowDays,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// How many full past days [exercise] averages (see `windowDays`).
|
||||
final int window;
|
||||
|
||||
/// Published sessions, or null when the PC never published any.
|
||||
final List<Session>? sessions;
|
||||
|
||||
/// Weight used for the kcal formulas, or null when unknown.
|
||||
final double? weightKg;
|
||||
|
||||
/// The 14-day daily means per formula (steps included), or null.
|
||||
final Map<String, double>? exercise;
|
||||
|
||||
/// Steps outside workouts by day.
|
||||
final Map<String, int> steps;
|
||||
|
||||
/// Mean daily net kcal of the counted steps alone, or null.
|
||||
final double? stepsKcal;
|
||||
|
||||
/// Asks for the Health Connect permission; null hides the button (not
|
||||
/// supported here, or already granted).
|
||||
final VoidCallback? onConnectSteps;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final list = sessions ?? const <Session>[];
|
||||
final kg = weightKg;
|
||||
final means = exercise;
|
||||
final newest = [...list]..sort((a, b) => b.day.compareTo(a.day));
|
||||
final stepKcal = stepsKcal;
|
||||
final lastDay = steps.isEmpty
|
||||
? null
|
||||
: steps.keys.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
|
||||
return BodySection(
|
||||
title: 'Activity',
|
||||
children: [
|
||||
if (sessions == null && steps.isEmpty)
|
||||
const BodyNote('No activity data yet -- published by the PC.'),
|
||||
if (means != null) ...[
|
||||
BodyNote('Mean daily exercise, last $window full days:'),
|
||||
for (final name in activityFormulas)
|
||||
BodyStatRow(name, '${whole(means[name] ?? 0)} kcal/day'),
|
||||
],
|
||||
if (lastDay != null && stepKcal != null)
|
||||
BodyNote(
|
||||
'Steps outside workouts: ${steps[lastDay]} on $lastDay; above '
|
||||
'$stepBaseline/day they add ${whole(stepKcal)} kcal/day '
|
||||
'(included above).',
|
||||
),
|
||||
if (onConnectSteps != null)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.directions_walk),
|
||||
label: const Text('Connect Health Connect steps'),
|
||||
onPressed: onConnectSteps,
|
||||
),
|
||||
),
|
||||
if (sessions != null && kg == null)
|
||||
const BodyNote('Enter your weight to estimate exercise kcal.'),
|
||||
if (sessions != null && kg != null) ...[
|
||||
const Divider(),
|
||||
if (newest.isEmpty)
|
||||
const BodyNote('No workouts in the last 28 days.'),
|
||||
for (final session in newest.take(activityListLimit))
|
||||
_SessionTile(result: sessionKcal(session, kg)),
|
||||
if (newest.isNotEmpty)
|
||||
const BodyNote('~ = no distance, MET estimate used instead.'),
|
||||
const BodyNote(methodsNote),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionTile extends StatelessWidget {
|
||||
const _SessionTile({required this.result});
|
||||
|
||||
final SessionKcal result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = result.session;
|
||||
final km = session.km;
|
||||
final distance = km == null ? '' : ', ${oneDecimal(km)} km';
|
||||
final kcal = [
|
||||
for (final name in activityFormulas)
|
||||
_formulaKcal(name, result.kcal[name]!, result.fallback.contains(name)),
|
||||
].join(' · ');
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('${session.day} ${session.label}'),
|
||||
subtitle: Text(
|
||||
'${whole(session.minutes)} min$distance\n$kcal kcal',
|
||||
),
|
||||
isThreeLine: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formulaKcal(String name, double kcal, bool fallback) =>
|
||||
'$name ${whole(kcal)}${fallback ? '~' : ''}';
|
||||
104
app/lib/widgets/body/body_section.dart
Normal file
104
app/lib/widgets/body/body_section.dart
Normal file
@ -0,0 +1,104 @@
|
||||
/// A titled card, the building block of every Body screen section.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A card with a title row and [children] stacked below it.
|
||||
class BodySection extends StatelessWidget {
|
||||
/// Creates a section.
|
||||
const BodySection({
|
||||
required this.title,
|
||||
required this.children,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Section heading.
|
||||
final String title;
|
||||
|
||||
/// Section content.
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A label on the left, a value on the right; one line of a stats card.
|
||||
class BodyStatRow extends StatelessWidget {
|
||||
/// Creates a row.
|
||||
const BodyStatRow(this.label, this.value, {this.color, super.key});
|
||||
|
||||
/// What the value is.
|
||||
final String label;
|
||||
|
||||
/// The formatted value.
|
||||
final String value;
|
||||
|
||||
/// Optional value color (e.g. a warning).
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label)),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A muted one-line explanation (missing data, notes).
|
||||
class BodyNote extends StatelessWidget {
|
||||
/// Creates a note.
|
||||
const BodyNote(this.text, {super.key});
|
||||
|
||||
/// The note.
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `74.99` -> `"75.0 kg"`, null -> `"n/a"`. Half-up, like the PC.
|
||||
String formatKg(double? kg) => kg == null ? 'n/a' : '${oneDecimal(kg)} kg';
|
||||
|
||||
/// [value] to one decimal, rounded half-up like the PC prints it.
|
||||
String oneDecimal(double value) => halfUp(value, 1).toStringAsFixed(1);
|
||||
|
||||
/// [value] as a whole number, rounded half-up like the PC prints it.
|
||||
String whole(double value) => halfUp(value).toInt().toString();
|
||||
105
app/lib/widgets/body/body_stats_cards.dart
Normal file
105
app/lib/widgets/body/body_stats_cards.dart
Normal file
@ -0,0 +1,105 @@
|
||||
/// BMI, ideal-weight and BMR cards -- read-only views of `body_calc.dart`.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// BMI, BMI Prime, Trefethen's BMI and the WHO category.
|
||||
class BmiCard extends StatelessWidget {
|
||||
/// Creates the card for [bio].
|
||||
const BmiCard({required this.bio, super.key});
|
||||
|
||||
/// The person.
|
||||
final Biometrics bio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final report = bmiReport(bio);
|
||||
final (low, high) = healthyRange(bio);
|
||||
final normal = report.category == 'normal';
|
||||
return BodySection(
|
||||
title: 'BMI',
|
||||
children: [
|
||||
BodyStatRow(
|
||||
'BMI',
|
||||
oneDecimal(report.bmi),
|
||||
color: normal ? null : Theme.of(context).colorScheme.error,
|
||||
),
|
||||
BodyStatRow('Category', report.category),
|
||||
BodyStatRow('BMI Prime', halfUp(report.prime, 2).toStringAsFixed(2)),
|
||||
BodyStatRow('Trefethen (new BMI)', oneDecimal(report.trefethen)),
|
||||
BodyStatRow(
|
||||
'Healthy weight for your height (BMI 18.5-24.9)',
|
||||
'${oneDecimal(low)}-${oneDecimal(high)} kg',
|
||||
),
|
||||
const BodyNote(bmiPrimeNote),
|
||||
const BodyNote(trefethenNote),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every ideal-weight formula; `n/a` where one is undefined.
|
||||
class IdealWeightCard extends StatelessWidget {
|
||||
/// Creates the card for [bio].
|
||||
const IdealWeightCard({required this.bio, super.key});
|
||||
|
||||
/// The person.
|
||||
final Biometrics bio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formulas = idealWeights(bio);
|
||||
final undefined = formulas.any((f) => f.value == null);
|
||||
final summary = idealSummary(formulas);
|
||||
return BodySection(
|
||||
title: 'Ideal weight',
|
||||
children: [
|
||||
for (final formula in formulas)
|
||||
BodyStatRow(formula.name, formatKg(formula.value)),
|
||||
if (summary != null) ...[
|
||||
const Divider(),
|
||||
BodyStatRow('Average of all', formatKg(summary.$1)),
|
||||
BodyStatRow(
|
||||
'Ideal range',
|
||||
'${oneDecimal(summary.$2)}-${oneDecimal(summary.$3)} kg',
|
||||
),
|
||||
],
|
||||
const BodyNote(brocaNote),
|
||||
if (undefined)
|
||||
const BodyNote(
|
||||
'Devine, Robinson, Miller and Hamwi are undefined below 152.4 cm.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resting metabolic rate by every formula; lean-mass ones need body fat.
|
||||
class BmrCard extends StatelessWidget {
|
||||
/// Creates the card for [bio].
|
||||
const BmrCard({required this.bio, this.bodyFatPct, super.key});
|
||||
|
||||
/// The person.
|
||||
final Biometrics bio;
|
||||
|
||||
/// The newest body fat %, or null.
|
||||
final double? bodyFatPct;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BodySection(
|
||||
title: 'Resting metabolism (BMR)',
|
||||
children: [
|
||||
for (final formula in bmrFormulas(bio, bodyFatPct))
|
||||
BodyStatRow(
|
||||
formula.name,
|
||||
formula.value == null
|
||||
? 'n/a -- log a body-fat %'
|
||||
: '${whole(formula.value!)} kcal',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
127
app/lib/widgets/body/goal_card.dart
Normal file
127
app/lib/widgets/body/goal_card.dart
Normal file
@ -0,0 +1,127 @@
|
||||
/// The Body screen's top line: "To [lose] [0.5] [kg] / [week] on
|
||||
/// [Moderate] eat 2019 kcal". Every bracket is tappable and opens a picker;
|
||||
/// the choice is saved (and synced) immediately. Informational only.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
import 'package:diet_guard_app/services/body_prefs.dart';
|
||||
import 'package:diet_guard_app/services/body_service.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/goal_pickers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The goal sentence and its answer.
|
||||
class GoalCard extends StatefulWidget {
|
||||
/// Creates the card.
|
||||
const GoalCard({
|
||||
required this.bio,
|
||||
this.exercise,
|
||||
this.bodyFatPct,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The person.
|
||||
final Biometrics bio;
|
||||
|
||||
/// Mean daily net exercise per formula (steps included), or null.
|
||||
final Map<String, double>? exercise;
|
||||
|
||||
/// The newest body fat %, which switches the base BMR, or null.
|
||||
final double? bodyFatPct;
|
||||
|
||||
@override
|
||||
State<GoalCard> createState() => _GoalCardState();
|
||||
}
|
||||
|
||||
class _GoalCardState extends State<GoalCard> {
|
||||
late Goal _goal = savedGoal(BodyService.instance);
|
||||
|
||||
Future<void> _pick(Future<Goal?> Function(BuildContext, Goal) picker) async {
|
||||
final picked = await picker(context, _goal);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _goal = picked);
|
||||
await setGoal(BodyService.instance, picked);
|
||||
}
|
||||
|
||||
Widget _choice(String text, Future<Goal?> Function(BuildContext, Goal) p) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: () => unawaited(_pick(p)),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bmr = tableBmr(widget.bio, widget.bodyFatPct);
|
||||
final target = goalTarget(_goal, bmr.value!, bmr.name, widget.exercise);
|
||||
final kcal = target.kcal;
|
||||
final tdee = target.tdee;
|
||||
final maintain = _goal.direction == 'maintain';
|
||||
return BodySection(
|
||||
title: 'Daily calorie target',
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runSpacing: AppSpacing.xs,
|
||||
children: [
|
||||
const Text('To'),
|
||||
_choice(_goal.direction, pickDirection),
|
||||
if (!maintain) ...[
|
||||
_choice(formatG(_goal.amount), pickAmount),
|
||||
_choice(_goal.weightUnit, pickWeightUnit),
|
||||
const Text('/'),
|
||||
_choice(_goal.timeUnit, pickTimeUnit),
|
||||
],
|
||||
const Text('on'),
|
||||
_choice(activityLabel(_goal.activity), pickActivity),
|
||||
if (kcal != null) ...[
|
||||
const Text('eat '),
|
||||
Text(
|
||||
'$kcal',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Text(' kcal'),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
if (kcal == null)
|
||||
const BodyNote('no workouts published yet (the PC publishes them).'),
|
||||
if (target.belowFloor)
|
||||
Text(
|
||||
'That is below the $minTargetKcal kcal floor.',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
if (tdee != null)
|
||||
BodyNote(
|
||||
'Maintenance ${whole(tdee)} kcal (${target.bmrName} resting '
|
||||
'burn x activity); 1 kg of fat = ${formatG(kcalPerKg)} kcal.',
|
||||
),
|
||||
const BodyNote(
|
||||
'Informational only — the budget is never changed from here.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
149
app/lib/widgets/body/goal_pickers.dart
Normal file
149
app/lib/widgets/body/goal_pickers.dart
Normal file
@ -0,0 +1,149 @@
|
||||
/// The pickers behind each tappable word of the goal sentence.
|
||||
///
|
||||
/// Each returns the edited [Goal], or null when dismissed.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<String?> _sheet(
|
||||
BuildContext context,
|
||||
String title,
|
||||
List<(String, String, String?)> options,
|
||||
String selected,
|
||||
) => showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
ListTile(title: Text(title)),
|
||||
for (final (value, label, subtitle) in options)
|
||||
ListTile(
|
||||
title: Text(label),
|
||||
subtitle: subtitle == null ? null : Text(subtitle),
|
||||
selected: value == selected,
|
||||
trailing: value == selected ? const Icon(Icons.check) : null,
|
||||
onTap: () => Navigator.of(context).pop(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Lose / maintain / gain.
|
||||
Future<Goal?> pickDirection(BuildContext context, Goal goal) async {
|
||||
final value = await _sheet(context, 'Direction', [
|
||||
for (final d in directions) (d, d, null),
|
||||
], goal.direction);
|
||||
return value == null ? null : goal.copyWith(direction: value);
|
||||
}
|
||||
|
||||
/// Kilograms, pounds, grams or stones.
|
||||
Future<Goal?> pickWeightUnit(BuildContext context, Goal goal) async {
|
||||
final value = await _sheet(context, 'Weight unit', [
|
||||
for (final u in weightUnits.keys) (u, u, null),
|
||||
], goal.weightUnit);
|
||||
return value == null ? null : goal.copyWith(weightUnit: value);
|
||||
}
|
||||
|
||||
/// Per day, week, month or year.
|
||||
Future<Goal?> pickTimeUnit(BuildContext context, Goal goal) async {
|
||||
final value = await _sheet(context, 'Per', [
|
||||
for (final u in timeUnits.keys) (u, u, null),
|
||||
], goal.timeUnit);
|
||||
return value == null ? null : goal.copyWith(timeUnit: value);
|
||||
}
|
||||
|
||||
/// The five levels, then the three measured formulas.
|
||||
Future<Goal?> pickActivity(BuildContext context, Goal goal) async {
|
||||
final meaning = {for (final l in activityLevels) l.key: l.meaning};
|
||||
final value = await _sheet(context, 'Activity', [
|
||||
for (final key in activityChoices)
|
||||
(
|
||||
key,
|
||||
activityLabel(key),
|
||||
meaning[key] ?? 'BMR x 1.2 + your real workouts and steps',
|
||||
),
|
||||
], goal.activity);
|
||||
return value == null ? null : goal.copyWith(activity: value);
|
||||
}
|
||||
|
||||
/// A preset amount, or any typed non-negative number.
|
||||
Future<Goal?> pickAmount(BuildContext context, Goal goal) async {
|
||||
final value = await showDialog<double>(
|
||||
context: context,
|
||||
builder: (context) => _AmountDialog(goal: goal),
|
||||
);
|
||||
return value == null ? null : goal.copyWith(amount: value);
|
||||
}
|
||||
|
||||
/// Owns its text controller, so the controller outlives the dialog's exit
|
||||
/// animation instead of being disposed while the field still paints.
|
||||
class _AmountDialog extends StatefulWidget {
|
||||
const _AmountDialog({required this.goal});
|
||||
|
||||
final Goal goal;
|
||||
|
||||
@override
|
||||
State<_AmountDialog> createState() => _AmountDialogState();
|
||||
}
|
||||
|
||||
class _AmountDialogState extends State<_AmountDialog> {
|
||||
late final _controller = TextEditingController(
|
||||
text: formatG(widget.goal.amount),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final typed = double.tryParse(_controller.text.replaceAll(',', '.'));
|
||||
if (typed != null && typed >= 0) Navigator.of(context).pop(typed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final goal = widget.goal;
|
||||
return AlertDialog(
|
||||
title: Text('How much (${goal.weightUnit} / ${goal.timeUnit})'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: AppSpacing.xs,
|
||||
children: [
|
||||
for (final preset in presetAmounts)
|
||||
ChoiceChip(
|
||||
label: Text(formatG(preset)),
|
||||
selected: preset == goal.amount,
|
||||
onSelected: (_) => Navigator.of(context).pop(preset),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(labelText: 'Other amount'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
|
||||
],
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(onPressed: _submit, child: const Text('OK')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
119
app/lib/widgets/body/profile_card.dart
Normal file
119
app/lib/widgets/body/profile_card.dart
Normal file
@ -0,0 +1,119 @@
|
||||
/// Birth date, height and sex -- the synced profile the formulas need.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Edits the profile; calls [onSaved] after a successful save.
|
||||
class ProfileCard extends StatefulWidget {
|
||||
/// Creates the card.
|
||||
const ProfileCard({required this.onSaved, super.key});
|
||||
|
||||
/// Called once the profile has been written.
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
State<ProfileCard> createState() => _ProfileCardState();
|
||||
}
|
||||
|
||||
class _ProfileCardState extends State<ProfileCard> {
|
||||
final _height = TextEditingController();
|
||||
String? _birth;
|
||||
String? _sex;
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final body = BodyService.instance;
|
||||
_birth = body.birth;
|
||||
_sex = body.sex;
|
||||
final height = body.heightCm;
|
||||
if (height != null) _height.text = oneDecimal(height);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_height.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickBirth() async {
|
||||
final now = DateTime.now();
|
||||
final initial = DateTime.tryParse(_birth ?? '') ?? DateTime(now.year - 30);
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: now,
|
||||
initialDate: initial,
|
||||
initialDatePickerMode: DatePickerMode.year,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _birth = localDateKey(picked));
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final height = double.tryParse(_height.text.replaceAll(',', '.'));
|
||||
if (height == null || height < 50 || height > 260) {
|
||||
setState(() => _status = 'Height must be 50-260 cm.');
|
||||
return;
|
||||
}
|
||||
await BodyService.instance.setProfile(
|
||||
birth: _birth,
|
||||
heightCm: height,
|
||||
sex: _sex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'Saved.');
|
||||
widget.onSaved();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BodySection(
|
||||
title: 'Profile',
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('Born: ${_birth ?? 'not set'}')),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.cake),
|
||||
label: const Text('Birth date'),
|
||||
onPressed: _pickBirth,
|
||||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _height,
|
||||
decoration: const InputDecoration(labelText: 'Height (cm)'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'm', label: Text('Male')),
|
||||
ButtonSegment(value: 'f', label: Text('Female')),
|
||||
],
|
||||
selected: {?_sex},
|
||||
emptySelectionAllowed: true,
|
||||
onSelectionChanged: (value) =>
|
||||
setState(() => _sex = value.isEmpty ? null : value.first),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: BodyNote(_status ?? 'Syncs to the PC.')),
|
||||
FilledButton(onPressed: _save, child: const Text('Save profile')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
132
app/lib/widgets/body/week_plan_card.dart
Normal file
132
app/lib/widgets/body/week_plan_card.dart
Normal file
@ -0,0 +1,132 @@
|
||||
/// Weekly-average planner: "I ate N on day X -- what now for the rest?"
|
||||
///
|
||||
/// Info only. Past days fill in from the food log; typing a number for any
|
||||
/// day overrides it (a planned feast on Saturday too).
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/week_plan.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// The planner.
|
||||
class WeekPlanCard extends StatefulWidget {
|
||||
/// Creates the card.
|
||||
const WeekPlanCard({
|
||||
required this.defaultTarget,
|
||||
required this.logged,
|
||||
this.today,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The average to aim for until the user types one (the daily budget).
|
||||
final int defaultTarget;
|
||||
|
||||
/// `YYYY-MM-DD` -> kcal eaten, from the food log.
|
||||
final Map<String, double> logged;
|
||||
|
||||
/// Overrides "today" for tests.
|
||||
final DateTime? today;
|
||||
|
||||
@override
|
||||
State<WeekPlanCard> createState() => _WeekPlanCardState();
|
||||
}
|
||||
|
||||
class _WeekPlanCardState extends State<WeekPlanCard> {
|
||||
final _target = TextEditingController();
|
||||
final List<TextEditingController> _days = List.generate(
|
||||
7,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_target.dispose();
|
||||
for (final controller in _days) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final target = int.tryParse(_target.text) ?? widget.defaultTarget;
|
||||
final typed = <int, int>{
|
||||
for (var i = 0; i < 7; i++)
|
||||
i: ?int.tryParse(_days[i].text),
|
||||
};
|
||||
final plan = planWeek(
|
||||
widget.today ?? DateTime.now(),
|
||||
target,
|
||||
widget.logged,
|
||||
typed,
|
||||
);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return BodySection(
|
||||
title: 'Week planner (info only)',
|
||||
children: [
|
||||
TextField(
|
||||
controller: _target,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Weekly average target (kcal/day)',
|
||||
hintText: '${widget.defaultTarget}',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
for (var i = 0; i < 7; i++)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(width: 48, child: Text(plan.days[i].weekday)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${plan.days[i].kcal} kcal ${_sourceLabel(plan.days[i])}',
|
||||
style: TextStyle(
|
||||
color: plan.days[i].source == 'plan' && plan.belowFloor
|
||||
? scheme.error
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: TextField(
|
||||
controller: _days[i],
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'ate…',
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
BodyNote(_summary(plan)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _sourceLabel(DayPlan day) => switch (day.source) {
|
||||
'typed' => '(typed)',
|
||||
'logged' => '(logged)',
|
||||
'unlogged' => '(not logged -- type it)',
|
||||
_ => '(plan)',
|
||||
};
|
||||
|
||||
String _summary(WeekPlan plan) {
|
||||
final per = plan.perRemaining;
|
||||
if (per == null) {
|
||||
return 'Every day is known: week average '
|
||||
'${whole(plan.weekAvg)} kcal.';
|
||||
}
|
||||
final floor = plan.belowFloor ? ' -- below the 1200 kcal floor!' : '';
|
||||
return 'Eat $per kcal on each open day to average '
|
||||
'${plan.targetAvg}$floor';
|
||||
}
|
||||
214
app/lib/widgets/body/weight_card.dart
Normal file
214
app/lib/widgets/body/weight_card.dart
Normal file
@ -0,0 +1,214 @@
|
||||
/// Dated-value entry (any day, save or delete) with its history chart.
|
||||
///
|
||||
/// One widget serves both logs -- weight and body fat -- so the two cards
|
||||
/// cannot drift apart; [WeightCard] and [BodyFatCard] only configure it.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:diet_guard_app/widgets/body/weight_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Weight: 20-400 kg; wake-alarm weigh-ins land here too.
|
||||
class WeightCard extends StatelessWidget {
|
||||
/// Creates the card.
|
||||
const WeightCard({required this.onChanged, super.key});
|
||||
|
||||
/// Called once a value has been written or deleted.
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = BodyService.instance;
|
||||
return DatedValueCard(
|
||||
title: 'Weight',
|
||||
fieldLabel: 'Weight (kg)',
|
||||
unit: 'kg',
|
||||
min: 20,
|
||||
max: 400,
|
||||
hint: 'Morning weigh-ins from wake-alarm appear here.',
|
||||
latest: body.latestWeight,
|
||||
series: body.weightSeries,
|
||||
onSave: body.setWeight,
|
||||
onDelete: body.deleteWeight,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Body fat: 3-70 %; the newest reading switches the table to Katch-McArdle.
|
||||
class BodyFatCard extends StatelessWidget {
|
||||
/// Creates the card.
|
||||
const BodyFatCard({required this.onChanged, super.key});
|
||||
|
||||
/// Called once a value has been written or deleted.
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = BodyService.instance;
|
||||
return DatedValueCard(
|
||||
title: 'Body fat',
|
||||
fieldLabel: 'Body fat (%)',
|
||||
unit: '%',
|
||||
min: minBodyFat,
|
||||
max: maxBodyFat,
|
||||
hint: 'With a body fat %, targets use the lean-mass (Katch-McArdle) BMR.',
|
||||
latest: body.latestBodyFat,
|
||||
series: body.bodyFatSeries,
|
||||
onSave: body.setBodyFat,
|
||||
onDelete: body.deleteBodyFat,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enters/deletes a day's value and charts the history.
|
||||
class DatedValueCard extends StatefulWidget {
|
||||
/// Creates the card.
|
||||
const DatedValueCard({
|
||||
required this.title,
|
||||
required this.fieldLabel,
|
||||
required this.unit,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.hint,
|
||||
required this.latest,
|
||||
required this.series,
|
||||
required this.onSave,
|
||||
required this.onDelete,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Section heading.
|
||||
final String title;
|
||||
|
||||
/// Input label.
|
||||
final String fieldLabel;
|
||||
|
||||
/// Unit suffix (`kg`, `%`).
|
||||
final String unit;
|
||||
|
||||
/// Smallest accepted value.
|
||||
final double min;
|
||||
|
||||
/// Largest accepted value.
|
||||
final double max;
|
||||
|
||||
/// Note shown until something is saved.
|
||||
final String hint;
|
||||
|
||||
/// Newest `(day, value)`, or null.
|
||||
final (String, double)? latest;
|
||||
|
||||
/// Every live `(day, value)`, oldest first.
|
||||
final List<(String, double)> series;
|
||||
|
||||
/// Writes a value for a day.
|
||||
final Future<void> Function(String day, double value) onSave;
|
||||
|
||||
/// Deletes a day.
|
||||
final Future<void> Function(String day) onDelete;
|
||||
|
||||
/// Called once a value has been written or deleted.
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
State<DatedValueCard> createState() => _DatedValueCardState();
|
||||
}
|
||||
|
||||
class _DatedValueCardState extends State<DatedValueCard> {
|
||||
final _value = TextEditingController();
|
||||
String _day = localDateKey(DateTime.now());
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_value.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _fmt(double value) => '${oneDecimal(value)} ${widget.unit}';
|
||||
|
||||
Future<void> _pickDay() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now(),
|
||||
initialDate: DateTime.parse(_day),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _day = localDateKey(picked));
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final value = double.tryParse(_value.text.replaceAll(',', '.'));
|
||||
if (value == null || value < widget.min || value > widget.max) {
|
||||
setState(
|
||||
() => _status =
|
||||
'${widget.title} must be ${whole(widget.min)}-'
|
||||
'${whole(widget.max)} ${widget.unit}.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await widget.onSave(_day, value);
|
||||
if (!mounted) return;
|
||||
_value.clear();
|
||||
setState(() => _status = 'Saved ${_fmt(value)} for $_day.');
|
||||
widget.onChanged();
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
await widget.onDelete(_day);
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'Deleted $_day.');
|
||||
widget.onChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latest = widget.latest;
|
||||
return BodySection(
|
||||
title: widget.title,
|
||||
children: [
|
||||
BodyStatRow(
|
||||
'Latest',
|
||||
latest == null ? 'none yet' : '${_fmt(latest.$2)} (${latest.$1})',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _value,
|
||||
decoration: InputDecoration(labelText: widget.fieldLabel),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(onPressed: _pickDay, child: Text(_day)),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: BodyNote(_status ?? widget.hint)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Delete this day',
|
||||
onPressed: _delete,
|
||||
),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
],
|
||||
),
|
||||
WeightChart(series: widget.series),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
137
app/lib/widgets/body/weight_chart.dart
Normal file
137
app/lib/widgets/body/weight_chart.dart
Normal file
@ -0,0 +1,137 @@
|
||||
/// The weight-history line chart with 30d / 90d / 1y / all range chips.
|
||||
///
|
||||
/// X is days since the first plotted point, so gaps between weigh-ins are
|
||||
/// drawn to scale rather than evenly spaced.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Selectable chart ranges, `(label, days)`; null days = everything.
|
||||
const List<(String, int?)> weightChartRanges = [
|
||||
('30d', 30),
|
||||
('90d', 90),
|
||||
('1y', 365),
|
||||
('All', null),
|
||||
];
|
||||
|
||||
/// Filters [series] (oldest first) to the last [days] before [today].
|
||||
List<(String, double)> seriesInRange(
|
||||
List<(String, double)> series,
|
||||
int? days,
|
||||
DateTime today,
|
||||
) {
|
||||
if (days == null) return series;
|
||||
final cutoff = DateTime(today.year, today.month, today.day - days);
|
||||
return [
|
||||
for (final point in series)
|
||||
if (!DateTime.parse(point.$1).isBefore(cutoff)) point,
|
||||
];
|
||||
}
|
||||
|
||||
/// A line chart of weights with its range selector.
|
||||
class WeightChart extends StatefulWidget {
|
||||
/// Creates the chart for [series] (`(YYYY-MM-DD, kg)`, oldest first).
|
||||
const WeightChart({required this.series, this.today, super.key});
|
||||
|
||||
/// Weights to plot.
|
||||
final List<(String, double)> series;
|
||||
|
||||
/// Overrides "today" for tests.
|
||||
final DateTime? today;
|
||||
|
||||
@override
|
||||
State<WeightChart> createState() => _WeightChartState();
|
||||
}
|
||||
|
||||
class _WeightChartState extends State<WeightChart> {
|
||||
int? _days = 90;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = seriesInRange(
|
||||
widget.series,
|
||||
_days,
|
||||
widget.today ?? DateTime.now(),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
children: [
|
||||
for (final (label, days) in weightChartRanges)
|
||||
ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: _days == days,
|
||||
onSelected: (_) => setState(() => _days = days),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: points.length < 2
|
||||
? const Center(child: Text('Need two readings for a graph.'))
|
||||
: _chart(context, points),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chart(BuildContext context, List<(String, double)> points) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final origin = DateTime.parse(points.first.$1);
|
||||
double x(String day) =>
|
||||
DateTime.parse(day).difference(origin).inHours / 24.0;
|
||||
final spots = [for (final (day, kg) in points) FlSpot(x(day), kg)];
|
||||
final kgs = [for (final (_, kg) in points) kg];
|
||||
final low = kgs.reduce((a, b) => a < b ? a : b);
|
||||
final high = kgs.reduce((a, b) => a > b ? a : b);
|
||||
final span = x(points.last.$1).clamp(1, double.infinity).toDouble();
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
minY: (low - 1).floorToDouble(),
|
||||
maxY: (high + 1).ceilToDouble(),
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(),
|
||||
rightTitles: const AxisTitles(),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: true, reservedSize: 40),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: span / 3,
|
||||
getTitlesWidget: (value, meta) => SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text(
|
||||
_dayLabel(origin, value),
|
||||
style: const TextStyle(fontSize: AppTextSize.caption),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
color: scheme.primary,
|
||||
barWidth: 3,
|
||||
dotData: FlDotData(show: spots.length <= 60),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _dayLabel(DateTime origin, double offset) {
|
||||
final day = DateTime(origin.year, origin.month, origin.day + offset.round());
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${two(day.day)}.${two(day.month)}';
|
||||
}
|
||||
@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
carp_serializable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: carp_serializable
|
||||
sha256: "4a78993c03577902f9996e29982c406d5d3c838fa174f18cc0eb53f49b352abd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -106,6 +114,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.13"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.2.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -146,6 +178,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -314,6 +354,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
health:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: health
|
||||
sha256: "8dd9625c9c26379a5753ad24c33af1d3dc5ce1f36f19c03e17c676c2f8fdbb80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -354,6 +402,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.1"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.3"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -864,6 +920,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
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.95+1
|
||||
version: 1.0.96+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -14,6 +14,7 @@ dependencies:
|
||||
ref: crdt_sync_dart-v0.11.1
|
||||
path: crdt_sync_dart
|
||||
crypto: 3.0.7
|
||||
fl_chart: 1.2.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_local_notifications: 22.3.1
|
||||
@ -27,6 +28,7 @@ dependencies:
|
||||
ref: github_device_auth-v0.3.1
|
||||
path: github_device_auth
|
||||
google_sign_in: 7.2.0
|
||||
health: 13.3.2
|
||||
http: 1.6.0
|
||||
idb_shim: 2.9.9
|
||||
path: 1.9.1
|
||||
|
||||
99
app/test/screens/body_screen_goal_test.dart
Normal file
99
app/test/screens/body_screen_goal_test.dart
Normal file
@ -0,0 +1,99 @@
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
import 'package:diet_guard_app/services/body_prefs.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_screen_support.dart';
|
||||
|
||||
void main() {
|
||||
late MemoryDocStore store;
|
||||
|
||||
setUp(() async {
|
||||
store = MemoryDocStore();
|
||||
await setUpBodyServices(store);
|
||||
});
|
||||
tearDown(tearDownBodyServices);
|
||||
|
||||
testWidgets('each word of the goal is a picker, saved at once', (
|
||||
tester,
|
||||
) async {
|
||||
await seedPerson(store);
|
||||
await pumpBody(tester);
|
||||
Future<void> choose(String word, String option) async {
|
||||
await tester.tap(find.text(word).first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(option).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await choose('lose', 'gain');
|
||||
await choose('kg', 'lb');
|
||||
await choose('week', 'month');
|
||||
await choose('Moderate', 'Measured (ACSM)');
|
||||
await tester.tap(find.text('0.5'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, '1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(goalToJson(savedGoal(BodyService.instance)), {
|
||||
'dir': 'gain',
|
||||
'amt': 1.0,
|
||||
'wu': 'lb',
|
||||
'tu': 'month',
|
||||
'act': 'measured:ACSM',
|
||||
});
|
||||
expect(
|
||||
BodyService.instance.document.profile[profileBirth]!.t,
|
||||
'2026-09-01T10:00:00+02:00',
|
||||
);
|
||||
|
||||
// A typed amount; junk is refused and the dialog stays open.
|
||||
final other = find.widgetWithText(TextField, 'Other amount');
|
||||
await tester.tap(find.text('1'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(other, '');
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('OK'), findsOneWidget);
|
||||
await tester.enterText(other, '2,5');
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(savedGoal(BodyService.instance).amount, 2.5);
|
||||
|
||||
// Cancel changes nothing; neither does dismissing a sheet.
|
||||
await tester.tap(find.text('2.5'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('gain'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(savedGoal(BodyService.instance).amount, 2.5);
|
||||
|
||||
await choose('gain', 'maintain');
|
||||
expect(find.text('lb'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('measured without data, and a goal below the floor', (
|
||||
tester,
|
||||
) async {
|
||||
final body = BodyService.instance;
|
||||
await body.setProfile(birth: '1990-01-01', heightCm: 180, sex: 'm');
|
||||
await body.setWeight('2026-09-20', 80);
|
||||
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
|
||||
await pumpBody(tester);
|
||||
expect(find.textContaining('no workouts published yet'), findsOneWidget);
|
||||
await tester.tap(find.text('Measured (MET)'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sedentary').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('1'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Other amount'), '3');
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('below the 1200 kcal floor'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
65
app/test/screens/body_screen_steps_test.dart
Normal file
65
app/test/screens/body_screen_steps_test.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/health_steps.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_screen_support.dart';
|
||||
import '../services/health_steps_test.dart' show FakeStepsSource;
|
||||
|
||||
void main() {
|
||||
final original = stepsSource;
|
||||
late MemoryDocStore store;
|
||||
|
||||
setUp(() async {
|
||||
store = MemoryDocStore();
|
||||
await setUpBodyServices(store);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
stepsSource = original;
|
||||
tearDownBodyServices();
|
||||
});
|
||||
|
||||
testWidgets('connect asks once; granted reads steps and hides it', (
|
||||
tester,
|
||||
) async {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final fake = FakeStepsSource(
|
||||
granted: false,
|
||||
intervals: [
|
||||
StepInterval(yesterday, yesterday.add(const Duration(hours: 1)), 6500),
|
||||
],
|
||||
)..grantOnRequest = false;
|
||||
stepsSource = fake;
|
||||
await pumpBody(tester);
|
||||
expect(fake.lastRead, isNull, reason: 'never reads before permission');
|
||||
|
||||
await tester.tap(find.text('Connect Health Connect steps'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Connect Health Connect steps'), findsOneWidget);
|
||||
|
||||
fake.grantOnRequest = true;
|
||||
await tester.tap(find.text('Connect Health Connect steps'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Connect Health Connect steps'), findsNothing);
|
||||
expect(
|
||||
BodyService.instance.document.steps[localDateKey(yesterday)]!.n,
|
||||
6500,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('already granted: refreshes on open, no button', (tester) async {
|
||||
final fake = FakeStepsSource();
|
||||
stepsSource = fake;
|
||||
await pumpBody(tester);
|
||||
expect(fake.lastRead, isNotNull);
|
||||
expect(find.text('Connect Health Connect steps'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a Health Connect error just hides nothing', (tester) async {
|
||||
stepsSource = FakeStepsSource()..failWith = StateError('gone');
|
||||
await pumpBody(tester);
|
||||
expect(find.text('Connect Health Connect steps'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
89
app/test/screens/body_screen_support.dart
Normal file
89
app/test/screens/body_screen_support.dart
Normal file
@ -0,0 +1,89 @@
|
||||
// Shared setup for the Body screen test files (split for the 250-line cap).
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/screens/body_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../services/body_test_support.dart';
|
||||
|
||||
export '../services/body_test_support.dart';
|
||||
|
||||
/// Points every service the screen reads at [store]; returns a teardown.
|
||||
Future<void> setUpBodyServices(MemoryDocStore store) async {
|
||||
LogStorageService.resetForTesting(store: store);
|
||||
AppSettingsService.resetForTesting(store: store);
|
||||
await BodyService.initForTesting(store);
|
||||
}
|
||||
|
||||
/// Undoes [setUpBodyServices].
|
||||
void tearDownBodyServices() {
|
||||
BodyService.resetForTesting();
|
||||
LogStorageService.resetForTesting();
|
||||
AppSettingsService.resetForTesting();
|
||||
}
|
||||
|
||||
/// Pumps the Body screen on a view tall enough to lay out every card.
|
||||
Future<void> pumpBody(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 12000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(const MaterialApp(home: BodyScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Yesterday's local date key.
|
||||
String yesterdayKey() =>
|
||||
localDateKey(DateTime.now().subtract(const Duration(days: 1)));
|
||||
|
||||
/// An 80 kg, 180 cm, 30-year-old man with a run, a lift and 9000 steps
|
||||
/// yesterday; optionally a saved [goal].
|
||||
Future<void> seedPerson(MemoryDocStore store, {Object? goal}) async {
|
||||
final today = DateTime.now();
|
||||
const t = '2026-09-01T10:00:00+02:00';
|
||||
store.documents[BodyService.documentName] = jsonEncode({
|
||||
'profile': {
|
||||
'birth': {'v': localDateKey(DateTime(today.year - 30, 1, 1)), 't': t},
|
||||
'height_cm': {'v': 180, 't': t},
|
||||
'sex': {'v': 'm', 't': t},
|
||||
if (goal != null) 'goal': {'v': goal, 't': t},
|
||||
},
|
||||
'weights': {
|
||||
'2026-09-20': {'kg': 81, 'src': 'phone', 't': '2026-09-20T07:00:00Z'},
|
||||
yesterdayKey(): {'kg': 80, 'src': 'manual', 't': '2026-09-20T07:00:00Z'},
|
||||
},
|
||||
'steps': {
|
||||
yesterdayKey(): {'n': 9000, 't': t},
|
||||
},
|
||||
'activity': {
|
||||
't': t,
|
||||
'sessions': [
|
||||
{
|
||||
'day': yesterdayKey(),
|
||||
'kind': 'run',
|
||||
'min': 60,
|
||||
'km': 10,
|
||||
'label': 'Evening run',
|
||||
'src': 'runnerup',
|
||||
},
|
||||
{
|
||||
'day': yesterdayKey(),
|
||||
'kind': 'strength',
|
||||
'min': 80,
|
||||
'label': 'StrongLifts A',
|
||||
'src': 'screen-locker',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await BodyService.initForTesting(store);
|
||||
}
|
||||
|
||||
/// The top edge of [finder]'s first match.
|
||||
double topOf(WidgetTester tester, Finder finder) =>
|
||||
tester.getTopLeft(finder.first).dy;
|
||||
154
app/test/screens/body_screen_test.dart
Normal file
154
app/test/screens/body_screen_test.dart
Normal file
@ -0,0 +1,154 @@
|
||||
import 'package:diet_guard_app/models/local_time.dart';
|
||||
import 'package:diet_guard_app/screens/body_screen.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/widgets/body/profile_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_screen_support.dart';
|
||||
|
||||
void main() {
|
||||
late MemoryDocStore store;
|
||||
|
||||
setUp(() async {
|
||||
store = MemoryDocStore();
|
||||
await setUpBodyServices(store);
|
||||
});
|
||||
tearDown(tearDownBodyServices);
|
||||
|
||||
testWidgets('with nothing set the profile is on top and asks for data', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpBody(tester);
|
||||
expect(find.textContaining('Set your profile and enter'), findsOneWidget);
|
||||
expect(find.textContaining('No activity data yet'), findsOneWidget);
|
||||
expect(find.text('Need two readings for a graph.'), findsNWidgets(2));
|
||||
expect(find.textContaining('Age unknown'), findsOneWidget);
|
||||
expect(
|
||||
topOf(tester, find.byType(ProfileCard)),
|
||||
lessThan(topOf(tester, find.text('Daily calorie target'))),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a weight without a profile asks for the profile', (
|
||||
tester,
|
||||
) async {
|
||||
await BodyService.instance.setWeight('2026-09-20', 80);
|
||||
await pumpBody(tester);
|
||||
expect(find.textContaining('Set your profile (birth'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a profile without a weight asks for the weight', (tester) async {
|
||||
await BodyService.instance.setProfile(
|
||||
birth: '1990-01-01',
|
||||
heightCm: 180,
|
||||
sex: 'f',
|
||||
);
|
||||
await pumpBody(tester);
|
||||
expect(find.text('Enter your weight to see the numbers.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a full profile: goal first, profile then body fat last', (
|
||||
tester,
|
||||
) async {
|
||||
await seedPerson(store);
|
||||
await pumpBody(tester);
|
||||
final goalTop = topOf(tester, find.text('Daily calorie target'));
|
||||
expect(goalTop, lessThan(topOf(tester, find.text('Weight'))));
|
||||
final profileTop = topOf(tester, find.byType(ProfileCard));
|
||||
expect(
|
||||
profileTop,
|
||||
greaterThan(topOf(tester, find.text('Week planner (info only)'))),
|
||||
);
|
||||
expect(topOf(tester, find.text('Body fat')), greaterThan(profileTop));
|
||||
// 80 kg, 180 cm, 30 y, male, lose 0.5 kg/week on Moderate:
|
||||
// 1780 x 1.55 - 550 = 2209.
|
||||
expect(find.text('2209'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('Maintenance 2759 kcal (Mifflin-St Jeor'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('24.7'), findsOneWidget);
|
||||
expect(find.text('59.9-80.7 kg'), findsOneWidget);
|
||||
expect(find.text('Average of all'), findsOneWidget);
|
||||
expect(find.textContaining('Broca = height'), findsOneWidget);
|
||||
expect(find.text('1780 kcal'), findsOneWidget);
|
||||
expect(find.text('n/a -- log a body-fat %'), findsNWidgets(2));
|
||||
// 5000 steps over baseline x 0.747 m x 0.5 x 80 kg = 149.4 kcal, and
|
||||
// all the data is from yesterday, so the window is that one day.
|
||||
expect(
|
||||
find.textContaining('Steps outside workouts: 9000 on ${yesterdayKey()}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('add 149 kcal/day'), findsOneWidget);
|
||||
expect(find.text('Mean daily exercise, last 1 full days:'), findsOneWidget);
|
||||
expect(find.textContaining('Evening run'), findsOneWidget);
|
||||
expect(find.textContaining('StrongLifts A'), findsOneWidget);
|
||||
expect(find.textContaining('Age 30'), findsOneWidget);
|
||||
expect(find.text('Connect Health Connect steps'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a body fat switches the target to Katch-McArdle', (
|
||||
tester,
|
||||
) async {
|
||||
await seedPerson(store);
|
||||
await pumpBody(tester);
|
||||
final field = find.widgetWithText(TextField, 'Body fat (%)');
|
||||
await tester.enterText(field, '99');
|
||||
await tester.tap(find.text('Save').at(1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Body fat must be 3-70 %.'), findsOneWidget);
|
||||
|
||||
await tester.enterText(field, '18,5');
|
||||
await tester.tap(find.text('Save').at(1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(BodyService.instance.latestBodyFat?.$2, 18.5);
|
||||
expect(find.textContaining('(Katch-McArdle resting'), findsOneWidget);
|
||||
expect(find.text('1778 kcal'), findsOneWidget);
|
||||
expect(find.text('2206'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('saving and deleting a weight updates the numbers', (
|
||||
tester,
|
||||
) async {
|
||||
await seedPerson(store);
|
||||
await pumpBody(tester);
|
||||
final field = find.widgetWithText(TextField, 'Weight (kg)');
|
||||
await tester.enterText(field, '5');
|
||||
await tester.tap(find.text('Save').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Weight must be 20-400 kg.'), findsOneWidget);
|
||||
|
||||
await tester.enterText(field, '90,5');
|
||||
await tester.tap(find.text('Save').first);
|
||||
await tester.pumpAndSettle();
|
||||
final today = localDateKey(DateTime.now());
|
||||
expect(BodyService.instance.latestWeight, (today, 90.5));
|
||||
expect(find.text('27.9'), findsOneWidget); // 90.5 / 1.8^2
|
||||
|
||||
await tester.tap(find.byTooltip('Delete this day').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(BodyService.instance.latestWeight?.$1, yesterdayKey());
|
||||
});
|
||||
|
||||
testWidgets('the profile form validates height and saves', (tester) async {
|
||||
await pumpBody(tester);
|
||||
final field = find.widgetWithText(TextField, 'Height (cm)');
|
||||
await tester.enterText(field, '9');
|
||||
await tester.tap(find.text('Save profile'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Height must be 50-260 cm.'), findsOneWidget);
|
||||
|
||||
await tester.enterText(field, '172.5');
|
||||
await tester.tap(find.text('Female'));
|
||||
await tester.tap(find.text('Save profile'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(BodyService.instance.heightCm, 172.5);
|
||||
expect(BodyService.instance.sex, 'f');
|
||||
expect(find.text('Saved.'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('kcalByDay of an empty log is empty', () {
|
||||
expect(kcalByDay(const {}), isEmpty);
|
||||
});
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/screens/body_screen.dart';
|
||||
import 'package:diet_guard_app/screens/calendar_screen.dart';
|
||||
import 'package:diet_guard_app/screens/food_bank_screen.dart';
|
||||
import 'package:diet_guard_app/screens/history_screen.dart';
|
||||
import 'package:diet_guard_app/screens/log_meal_screen.dart';
|
||||
import 'package:diet_guard_app/screens/settings_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/document_store_io.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
@ -17,6 +19,8 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:url_launcher_platform_interface/link.dart';
|
||||
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
|
||||
|
||||
import '../services/body_test_support.dart';
|
||||
|
||||
/// Stub launcher that records the URL instead of opening it -- same pattern
|
||||
/// as settings_screen_test.dart's fake, duplicated locally per this
|
||||
/// codebase's convention of small per-file fakes over a shared mock.
|
||||
@ -43,7 +47,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
|
||||
/// it as a real image instead of throwing on bogus bytes.
|
||||
|
||||
void main() {
|
||||
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
@ -51,9 +54,7 @@ void main() {
|
||||
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
BudgetHistoryService.resetForTesting(
|
||||
store: FileDocumentStore(tempDir),
|
||||
);
|
||||
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@ -89,9 +90,7 @@ void main() {
|
||||
expect(find.byType(HistoryScreen), findsOneWidget);
|
||||
});
|
||||
});
|
||||
testWidgets('the calendar icon navigates to CalendarScreen', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('the calendar icon navigates to CalendarScreen', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||
await settle(tester);
|
||||
@ -102,6 +101,19 @@ void main() {
|
||||
expect(find.byType(CalendarScreen), findsOneWidget);
|
||||
});
|
||||
});
|
||||
testWidgets('the body icon navigates to BodyScreen', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await BodyService.initForTesting(MemoryDocStore());
|
||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Body'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(BodyScreen), findsOneWidget);
|
||||
BodyService.resetForTesting();
|
||||
});
|
||||
});
|
||||
testWidgets('the settings icon navigates to SettingsScreen', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||
|
||||
@ -36,9 +36,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'does not push when sync is unconfigured (defaults to off)',
|
||||
(tester) async {
|
||||
testWidgets('does not push when sync is unconfigured (defaults to off)', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
var puts = 0;
|
||||
@ -55,8 +55,7 @@ void main() {
|
||||
|
||||
expect(puts, 0);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('pushes on launch when sync is configured', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
@ -170,7 +169,13 @@ void main() {
|
||||
await tester.enterText(find.byType(TextField).at(0), 'push-on-log');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Log meal'));
|
||||
// Wait for the push itself rather than a fixed 200 ms: under the
|
||||
// CPU-capped pre-commit gate the real async hop can take longer, and
|
||||
// a wall-clock guess made this flaky there (passes 5/5 standalone).
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (puts < 2 && DateTime.now().isBefore(deadline)) {
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
// The new meal is pushed right away, not left for a lifecycle event.
|
||||
expect(puts, 2);
|
||||
|
||||
36
app/test/services/activity_kcal_share_test.dart
Normal file
36
app/test/services/activity_kcal_share_test.dart
Normal file
@ -0,0 +1,36 @@
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final today = DateTime(2026, 9, 28);
|
||||
const run = Session(
|
||||
day: '2026-09-22',
|
||||
kind: 'run',
|
||||
minutes: 60,
|
||||
km: 6,
|
||||
label: 'Run',
|
||||
source: 'runnerup',
|
||||
);
|
||||
|
||||
group('extraShare', () {
|
||||
test('is zero with no past data', () {
|
||||
expect(extraShare(const [], today, const {'2026-09-28': 50}), 0);
|
||||
});
|
||||
|
||||
test('uses the same window as dailyExercise, so it adds up', () {
|
||||
const steps = {'2026-09-26': 60.0, '2026-09-01': 999.0};
|
||||
// The 09-01 step day stretches the window to the full 14 days but is
|
||||
// itself outside it; only 09-26 counts.
|
||||
final share = extraShare(const [run], today, steps);
|
||||
expect(share, closeTo(60 / 14, 1e-9));
|
||||
final withSteps = dailyExercise(const [run], 80, today, steps);
|
||||
final without = dailyExercise(
|
||||
const [run],
|
||||
80,
|
||||
today,
|
||||
{'2026-09-01': 0.0},
|
||||
);
|
||||
expect(withSteps['MET']! - without['MET']!, closeTo(share, 1e-9));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -7,6 +7,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/services/document_store_io.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/background_sync_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
@ -33,13 +34,10 @@ void main() {
|
||||
// .init() call short-circuits instead of hitting the real (unmocked in
|
||||
// this test) path_provider channel.
|
||||
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
BudgetHistoryService.resetForTesting(
|
||||
store: FileDocumentStore(tempDir),
|
||||
);
|
||||
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
KuchniaCredentialService.resetForTesting(
|
||||
store: FileDocumentStore(tempDir),
|
||||
);
|
||||
BodyService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
KuchniaCredentialService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@ -48,6 +46,7 @@ void main() {
|
||||
AppSettingsService.resetForTesting();
|
||||
BudgetHistoryService.resetForTesting();
|
||||
MealScheduleService.resetForTesting();
|
||||
BodyService.resetForTesting();
|
||||
KuchniaCredentialService.resetForTesting();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
@ -90,10 +89,10 @@ void main() {
|
||||
|
||||
expect(ok, isTrue);
|
||||
// syncLog always pushes, even an empty merged result: food_log.json,
|
||||
// budget.json, food_bank.json, food_bank_manual.json, kuchnia.json.
|
||||
// Five data files plus this device's revision, which is what lets a
|
||||
// later tick skip an unchanged peer.
|
||||
expect(puts, 6);
|
||||
// budget.json, food_bank.json, food_bank_manual.json, kuchnia.json,
|
||||
// body.json. Six data files plus this device's revision, which is what
|
||||
// lets a later tick skip an unchanged peer.
|
||||
expect(puts, 7);
|
||||
});
|
||||
|
||||
test('pushes under the persisted uuid, not the legacy role id', () async {
|
||||
|
||||
220
app/test/services/body_parity_test.dart
Normal file
220
app/test/services/body_parity_test.dart
Normal file
@ -0,0 +1,220 @@
|
||||
/// The Dart half of the cross-language Body parity gate.
|
||||
///
|
||||
/// `diet_guard/tests/test_body_parity.py` asserts the same expectations
|
||||
/// against the same `tests/fixtures/body_calc.json` (built from the Python
|
||||
/// reference by `scripts/build_body_fixture.py`), so the PC and the phone
|
||||
/// print the same BMI, ideal weights, calorie table, exercise kcal and week
|
||||
/// plan for the same person.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
import 'package:diet_guard_app/services/body_prefs.dart';
|
||||
import 'package:diet_guard_app/services/body_steps.dart';
|
||||
import 'package:diet_guard_app/services/week_plan.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Directory get _repoRoot {
|
||||
var dir = Directory.current;
|
||||
while (!File('${dir.path}/pubspec.yaml').existsSync()) {
|
||||
final parent = dir.parent;
|
||||
if (parent.path == dir.path) {
|
||||
fail('could not locate app/ from ${Directory.current.path}');
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
return dir.parent;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _loadFixture() =>
|
||||
jsonDecode(
|
||||
File(
|
||||
'${_repoRoot.path}/tests/fixtures/body_calc.json',
|
||||
).readAsStringSync(),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
|
||||
void _close(Object? actual, Object? expected, String what) {
|
||||
if (expected == null) {
|
||||
expect(actual, isNull, reason: what);
|
||||
return;
|
||||
}
|
||||
expect(actual, isNotNull, reason: what);
|
||||
expect(
|
||||
actual! as num,
|
||||
closeTo((expected as num).toDouble(), 1e-9),
|
||||
reason: what,
|
||||
);
|
||||
}
|
||||
|
||||
Biometrics _bio(Map<String, dynamic> raw) => Biometrics(
|
||||
weightKg: (raw['weight_kg'] as num).toDouble(),
|
||||
heightCm: (raw['height_cm'] as num).toDouble(),
|
||||
ageYears: (raw['age_years'] as num).toDouble(),
|
||||
isMale: raw['is_male'] as bool,
|
||||
);
|
||||
|
||||
Session _session(Map<String, dynamic> raw) => Session(
|
||||
day: raw['day'] as String,
|
||||
kind: raw['kind'] as String,
|
||||
minutes: (raw['minutes'] as num).toDouble(),
|
||||
km: (raw['km'] as num?)?.toDouble(),
|
||||
label: raw['label'] as String,
|
||||
source: raw['source'] as String,
|
||||
start: raw['start'] as String?,
|
||||
end: raw['end'] as String?,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final fixture = _loadFixture();
|
||||
final today = DateTime.parse(fixture['today'] as String);
|
||||
final steps = (fixture['steps'] as Map<String, dynamic>).map(
|
||||
(day, n) => MapEntry(day, n as int),
|
||||
);
|
||||
final sessions = (fixture['sessions'] as List<dynamic>)
|
||||
.map((raw) => _session(raw as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
group('people', () {
|
||||
final people = fixture['people'] as List<dynamic>;
|
||||
for (var index = 0; index < people.length; index++) {
|
||||
final person = people[index] as Map<String, dynamic>;
|
||||
final bio = _bio(person['bio'] as Map<String, dynamic>);
|
||||
test('person $index: BMI, ideal weight, BMR', () {
|
||||
final bmi = bmiReport(bio);
|
||||
final expected = person['bmi'] as Map<String, dynamic>;
|
||||
_close(bmi.bmi, expected['bmi'], 'bmi');
|
||||
_close(bmi.prime, expected['prime'], 'prime');
|
||||
_close(bmi.trefethen, expected['trefethen'], 'trefethen');
|
||||
expect(bmi.category, expected['category']);
|
||||
final fat = (person['body_fat_pct'] as num).toDouble();
|
||||
for (final (name, list) in [
|
||||
('ideal', idealWeights(bio)),
|
||||
('bmr', bmrFormulas(bio)),
|
||||
('bmr_with_fat', bmrFormulas(bio, fat)),
|
||||
]) {
|
||||
final rows = person[name] as List<dynamic>;
|
||||
expect(list, hasLength(rows.length));
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
final row = rows[i] as List<dynamic>;
|
||||
expect(list[i].name, row[0]);
|
||||
_close(list[i].value, row[1], '$name ${row[0]}');
|
||||
}
|
||||
}
|
||||
});
|
||||
test('person $index: healthy range and ideal summary', () {
|
||||
final (low, high) = healthyRange(bio);
|
||||
final range = person['healthy_range'] as List<dynamic>;
|
||||
_close(low, range[0], 'healthy low');
|
||||
_close(high, range[1], 'healthy high');
|
||||
final summary = idealSummary(idealWeights(bio))!;
|
||||
final expected = person['ideal_summary'] as List<dynamic>;
|
||||
_close(summary.$1, expected[0], 'ideal avg');
|
||||
_close(summary.$2, expected[1], 'ideal min');
|
||||
_close(summary.$3, expected[2], 'ideal max');
|
||||
});
|
||||
test('person $index: steps, exercise and goal targets', () {
|
||||
_close(strideM(bio), person['stride_m'], 'stride');
|
||||
final stepKcals = (person['step_kcal'] as Map<String, dynamic>);
|
||||
final byDay = <String, double>{};
|
||||
for (final entry in steps.entries) {
|
||||
byDay[entry.key] = stepKcal(entry.value, bio);
|
||||
_close(byDay[entry.key], stepKcals[entry.key], 'steps ${entry.key}');
|
||||
}
|
||||
final exercise = dailyExercise(sessions, bio.weightKg, today, byDay);
|
||||
for (final (key, actual) in [
|
||||
('exercise', exercise),
|
||||
('exercise_no_steps', dailyExercise(sessions, bio.weightKg, today)),
|
||||
// History younger than 14 days: averaged from its first day.
|
||||
(
|
||||
'exercise_short',
|
||||
dailyExercise(
|
||||
[
|
||||
for (final x in sessions)
|
||||
if (x.day.compareTo('2026-09-24') >= 0) x,
|
||||
],
|
||||
bio.weightKg,
|
||||
today,
|
||||
),
|
||||
),
|
||||
// Only today's in-progress session: no past data, all zero.
|
||||
(
|
||||
'exercise_today_only',
|
||||
dailyExercise(
|
||||
[
|
||||
for (final x in sessions)
|
||||
if (x.day == '2026-09-28') x,
|
||||
],
|
||||
bio.weightKg,
|
||||
today,
|
||||
),
|
||||
),
|
||||
]) {
|
||||
final expected = person[key] as Map<String, dynamic>;
|
||||
expect(actual.keys.toList(), expected.keys.toList());
|
||||
for (final name in expected.keys) {
|
||||
_close(actual[name], expected[name], '$key $name');
|
||||
}
|
||||
}
|
||||
for (final raw in person['targets'] as List<dynamic>) {
|
||||
final row = raw as Map<String, dynamic>;
|
||||
final fat = (row['body_fat_pct'] as num?)?.toDouble();
|
||||
final goal = goalFromJson(row['goal']);
|
||||
expect(goalToJson(goal), row['goal']);
|
||||
final bmr = tableBmr(bio, fat);
|
||||
final target = goalTarget(goal, bmr.value!, bmr.name, exercise);
|
||||
final what = '${row['goal']} fat=$fat';
|
||||
expect(target.kcal, row['kcal'], reason: what);
|
||||
_close(target.tdee, row['tdee'], what);
|
||||
expect(target.bmrName, row['bmr_name'], reason: what);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('session kcal at 80 kg', () {
|
||||
final expected = fixture['session_kcal_80kg'] as List<dynamic>;
|
||||
for (var i = 0; i < sessions.length; i++) {
|
||||
final result = sessionKcal(sessions[i], 80);
|
||||
final row = expected[i] as Map<String, dynamic>;
|
||||
final kcal = row['kcal'] as Map<String, dynamic>;
|
||||
expect(result.kcal.keys.toList(), kcal.keys.toList());
|
||||
for (final name in kcal.keys) {
|
||||
_close(result.kcal[name], kcal[name], 'session $i $name');
|
||||
}
|
||||
expect(result.fallback.toList()..sort(), row['fallback']);
|
||||
}
|
||||
});
|
||||
|
||||
test('week plans', () {
|
||||
for (final raw in fixture['plans'] as List<dynamic>) {
|
||||
final plan = raw as Map<String, dynamic>;
|
||||
final result = planWeek(
|
||||
DateTime.parse(plan['today'] as String),
|
||||
plan['target'] as int,
|
||||
(plan['logged'] as Map<String, dynamic>).map(
|
||||
(k, v) => MapEntry(k, (v as num).toDouble()),
|
||||
),
|
||||
(plan['typed'] as Map<String, dynamic>).map(
|
||||
(k, v) => MapEntry(int.parse(k), v as int),
|
||||
),
|
||||
);
|
||||
final expected = plan['expected'] as Map<String, dynamic>;
|
||||
expect(result.targetAvg, expected['target_avg']);
|
||||
expect(result.perRemaining, expected['per_remaining']);
|
||||
_close(result.weekAvg, expected['week_avg'], 'week_avg');
|
||||
final days = expected['days'] as List<dynamic>;
|
||||
for (var i = 0; i < 7; i++) {
|
||||
final day = days[i] as Map<String, dynamic>;
|
||||
expect(result.days[i].day, day['day']);
|
||||
expect(result.days[i].weekday, day['weekday']);
|
||||
expect(result.days[i].kcal, day['kcal']);
|
||||
expect(result.days[i].source, day['source']);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
75
app/test/services/body_prefs_test.dart
Normal file
75
app/test/services/body_prefs_test.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
import 'package:diet_guard_app/services/body_prefs.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
|
||||
void main() {
|
||||
late BodyService body;
|
||||
|
||||
setUp(() async => body = await BodyService.initForTesting(MemoryDocStore()));
|
||||
tearDown(BodyService.resetForTesting);
|
||||
|
||||
test('unset reads as the default goal', () {
|
||||
final goal = savedGoal(body);
|
||||
expect(goalToJson(goal), goalToJson(defaultGoal));
|
||||
});
|
||||
|
||||
test('each part falls back to the default on its own', () {
|
||||
final goal = goalFromJson({
|
||||
'dir': 'gain',
|
||||
'amt': -1,
|
||||
'wu': 'furlong',
|
||||
'tu': 'month',
|
||||
'act': 'measured:ACSM',
|
||||
});
|
||||
expect(goal.direction, 'gain');
|
||||
expect(goal.amount, defaultGoal.amount);
|
||||
expect(goal.weightUnit, 'kg');
|
||||
expect(goal.timeUnit, 'month');
|
||||
expect(goal.activity, 'measured:ACSM');
|
||||
expect(goalFromJson({'amt': true, 'act': 3}).activity, 'moderate');
|
||||
expect(goalToJson(goalFromJson('nope')), goalToJson(defaultGoal));
|
||||
});
|
||||
|
||||
test('saving syncs one field and never re-stamps the profile', () async {
|
||||
await body.setProfile(
|
||||
birth: '1990-01-01',
|
||||
now: DateTime.parse('2026-09-01T10:00:00'),
|
||||
);
|
||||
const goal = Goal('gain', 2, 'lb', 'month', 'extra');
|
||||
await setGoal(body, goal);
|
||||
expect(body.profileValue(goalField), goalToJson(goal));
|
||||
expect(goalToJson(savedGoal(body)), goalToJson(goal));
|
||||
expect(body.document.profile[profileBirth]!.t, startsWith('2026-09-01T10'));
|
||||
});
|
||||
|
||||
test('goal arithmetic: units, maintain and measured without data', () {
|
||||
expect(const Goal('maintain', 9, 'kg', 'day', 'light').kgPerDay(), 0);
|
||||
expect(
|
||||
const Goal('gain', 7, 'kg', 'week', 'light').kgPerDay(),
|
||||
closeTo(-1, 1e-12),
|
||||
);
|
||||
final missing = goalTarget(
|
||||
const Goal('lose', 1, 'kg', 'week', 'measured:MET'),
|
||||
1800,
|
||||
'Mifflin-St Jeor',
|
||||
null,
|
||||
);
|
||||
expect(missing.kcal, isNull);
|
||||
expect(missing.tdee, isNull);
|
||||
expect(missing.belowFloor, isFalse);
|
||||
expect(
|
||||
goalTarget(defaultGoal.copyWith(activity: '?'), 1800, 'x', null).kcal,
|
||||
isNull,
|
||||
);
|
||||
expect(activityLabel('measured:Per-km'), 'Measured (Per-km)');
|
||||
expect(activityLabel('very'), 'Very active');
|
||||
expect(activityLabel('?'), '?');
|
||||
expect(formatG(1 / 3), '0.333333');
|
||||
expect(formatG(0.25), '0.25');
|
||||
expect(formatG(1e20), '1.00000e+20');
|
||||
});
|
||||
}
|
||||
194
app/test/services/body_service_test.dart
Normal file
194
app/test/services/body_service_test.dart
Normal file
@ -0,0 +1,194 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
|
||||
final _t0 = DateTime.parse('2026-09-27T08:00:00');
|
||||
|
||||
void main() {
|
||||
late MemoryDocStore store;
|
||||
|
||||
setUp(() async {
|
||||
store = MemoryDocStore();
|
||||
await BodyService.initForTesting(store);
|
||||
});
|
||||
tearDown(BodyService.resetForTesting);
|
||||
|
||||
test('an empty store reads as an empty document', () {
|
||||
final body = BodyService.instance;
|
||||
expect(body.birth, isNull);
|
||||
expect(body.heightCm, isNull);
|
||||
expect(body.sex, isNull);
|
||||
expect(body.latestWeight, isNull);
|
||||
expect(body.sessions, isNull);
|
||||
expect(BodyService.isInitialized, isTrue);
|
||||
});
|
||||
|
||||
test('a corrupt document reads as empty', () async {
|
||||
store.documents[BodyService.documentName] = '{not json';
|
||||
final body = await BodyService.initForTesting(store);
|
||||
expect(body.document.weights, isEmpty);
|
||||
});
|
||||
|
||||
test('setProfile stamps only the fields given and persists', () async {
|
||||
final body = BodyService.instance;
|
||||
await body.setProfile(birth: '1995-03-01', heightCm: 180, now: _t0);
|
||||
await body.setProfile(sex: 'm', now: _t0.add(const Duration(hours: 1)));
|
||||
expect(body.birth, '1995-03-01');
|
||||
expect(body.heightCm, 180);
|
||||
expect(body.sex, 'm');
|
||||
final reloaded = await BodyService.initForTesting(store);
|
||||
expect(
|
||||
reloaded.document.profile[profileBirth]!.t,
|
||||
startsWith('2026-09-27T08'),
|
||||
);
|
||||
expect(
|
||||
reloaded.document.profile[profileSex]!.t,
|
||||
startsWith('2026-09-27T09'),
|
||||
);
|
||||
});
|
||||
|
||||
test('weights: set, delete (tombstone), series and latest', () async {
|
||||
final body = BodyService.instance;
|
||||
await body.setWeight('2026-09-25', 81.5, now: _t0);
|
||||
await body.setWeight('2026-09-27', 80.9, now: _t0);
|
||||
await body.setWeight('2026-09-26', 81.2, now: _t0);
|
||||
expect(body.weightSeries.map((p) => p.$1), [
|
||||
'2026-09-25',
|
||||
'2026-09-26',
|
||||
'2026-09-27',
|
||||
]);
|
||||
await body.deleteWeight('2026-09-27', now: _t0);
|
||||
expect(body.latestWeight, ('2026-09-26', 81.2));
|
||||
expect(body.document.weights['2026-09-27']!.kg, isNull);
|
||||
final saved = jsonDecode(store.documents[BodyService.documentName]!) as Map;
|
||||
expect((saved['weights'] as Map)['2026-09-27'], containsPair('kg', null));
|
||||
});
|
||||
|
||||
test('applyCandidates only replaces missing or older entries', () async {
|
||||
final body = BodyService.instance;
|
||||
await body.setWeight('2026-09-26', 82, now: _t0);
|
||||
final changed = await body.applyCandidates({
|
||||
'2026-09-26': const WeightEntry(
|
||||
kg: 70,
|
||||
src: 'phone',
|
||||
t: '2026-09-26T00:00:00+00:00',
|
||||
),
|
||||
'2026-09-25': const WeightEntry(
|
||||
kg: 81,
|
||||
src: 'phone',
|
||||
t: '2026-09-25T00:00:00+00:00',
|
||||
),
|
||||
});
|
||||
expect(changed, isTrue);
|
||||
expect(body.document.weights['2026-09-26']!.kg, 82);
|
||||
expect(body.document.weights['2026-09-25']!.src, 'phone');
|
||||
final newer = await body.applyCandidates({
|
||||
'2026-09-26': const WeightEntry(
|
||||
kg: 79,
|
||||
src: 'phone',
|
||||
t: '2026-09-28T07:00:00+00:00',
|
||||
),
|
||||
});
|
||||
expect(newer, isTrue);
|
||||
expect(body.document.weights['2026-09-26']!.kg, 79);
|
||||
expect(await body.applyCandidates(const {}), isFalse);
|
||||
});
|
||||
|
||||
test('sessions parse the published activity, skipping malformed', () async {
|
||||
store.documents[BodyService.documentName] = jsonEncode({
|
||||
'activity': {
|
||||
't': '2026-09-27T10:00:00+02:00',
|
||||
'sessions': [
|
||||
{
|
||||
'day': '2026-09-26',
|
||||
'kind': 'run',
|
||||
'min': 30,
|
||||
'km': 5,
|
||||
'label': 'Run',
|
||||
'src': 'runnerup',
|
||||
},
|
||||
{'day': '2026-09-26', 'kind': 'strength', 'min': 60},
|
||||
{'day': 3},
|
||||
'junk',
|
||||
{'day': '2026-09-26', 'kind': 'run', 'min': 30, 'km': 'far'},
|
||||
],
|
||||
},
|
||||
});
|
||||
final body = await BodyService.initForTesting(store);
|
||||
final sessions = body.sessions!;
|
||||
expect(sessions, hasLength(2));
|
||||
expect(sessions[0].km, 5);
|
||||
expect(sessions[1].label, 'strength');
|
||||
expect(sessions[1].source, '');
|
||||
});
|
||||
|
||||
test('applyMerged replaces the document', () async {
|
||||
await BodyService.instance.applyMerged(
|
||||
const BodyDocument(
|
||||
weights: {'2026-09-20': WeightEntry(kg: 90, src: 'init', t: 'x')},
|
||||
),
|
||||
);
|
||||
expect(BodyService.instance.latestWeight, ('2026-09-20', 90.0));
|
||||
});
|
||||
|
||||
test('document parsing drops malformed parts', () {
|
||||
final doc = BodyDocument.fromJson({
|
||||
'profile': {
|
||||
'birth': {'v': '1990-01-01'},
|
||||
'sex': {'v': 'f', 't': 'x'},
|
||||
},
|
||||
'weights': {
|
||||
'yesterday': {'kg': 80, 't': 'x'},
|
||||
'2026-01-01': {'kg': 5, 't': 'x'},
|
||||
'2026-01-02': {'kg': 80},
|
||||
'2026-01-03': {'kg': 80, 't': 'x'},
|
||||
},
|
||||
'activity': {'sessions': 'nope', 't': 'x'},
|
||||
});
|
||||
expect(doc.profile.keys, ['sex']);
|
||||
expect(doc.weights.keys, ['2026-01-03']);
|
||||
expect(doc.weights['2026-01-03']!.src, 'manual');
|
||||
expect(doc.activity, isNull);
|
||||
expect(BodyDocument.fromJson('nope').weights, isEmpty);
|
||||
});
|
||||
|
||||
test('body fat: set (rounded), delete, series and latest', () async {
|
||||
final body = BodyService.instance;
|
||||
await body.setBodyFat('2026-09-25', 20.04, now: _t0);
|
||||
await body.setBodyFat('2026-09-26', 19.55, now: _t0);
|
||||
expect(body.latestBodyFat, ('2026-09-26', 19.6));
|
||||
await body.deleteBodyFat('2026-09-26', now: _t0);
|
||||
expect(body.latestBodyFat, ('2026-09-25', 20.0));
|
||||
expect(body.document.bodyFat['2026-09-26']!.pct, isNull);
|
||||
final reloaded = await BodyService.initForTesting(store);
|
||||
expect(reloaded.bodyFatSeries, [('2026-09-25', 20.0)]);
|
||||
});
|
||||
|
||||
test('profileComplete needs a real date, a height and m/f', () async {
|
||||
final body = BodyService.instance;
|
||||
expect(body.profileComplete, isFalse);
|
||||
await body.setProfile(birth: 'soon', heightCm: 180, sex: 'x');
|
||||
expect(body.profileComplete, isFalse);
|
||||
expect(body.sex, isNull);
|
||||
await body.setProfile(birth: '1990-01-01', sex: 'f');
|
||||
expect(body.profileComplete, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'setProfileFields ignores an empty map and keeps other stamps',
|
||||
() async {
|
||||
final body = BodyService.instance;
|
||||
await body.setProfile(heightCm: 180, now: _t0);
|
||||
await body.setProfileFields(const {});
|
||||
await body.setProfileFields({
|
||||
'tbl_act': ['light'],
|
||||
}, now: _t0.add(const Duration(days: 1)));
|
||||
expect(body.profileValue('tbl_act'), ['light']);
|
||||
expect(body.document.profile[profileHeight]!.t, startsWith('2026-09-27'));
|
||||
},
|
||||
);
|
||||
}
|
||||
74
app/test/services/body_test_support.dart
Normal file
74
app/test/services/body_test_support.dart
Normal file
@ -0,0 +1,74 @@
|
||||
// Shared fakes for the Body feature's tests: an in-memory DocumentStore and
|
||||
// an in-memory RemoteStore (path -> text), enough for `syncLog` and the
|
||||
// wake-alarm ingestion, which only list directories and read/write files.
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/document_store.dart';
|
||||
|
||||
/// Documents kept in a map.
|
||||
class MemoryDocStore implements DocumentStore {
|
||||
/// Stored documents by name.
|
||||
final Map<String, String> documents = {};
|
||||
|
||||
@override
|
||||
Future<String?> read(String name) async => documents[name];
|
||||
|
||||
@override
|
||||
Future<void> write(String name, String contents) async {
|
||||
documents[name] = contents;
|
||||
}
|
||||
}
|
||||
|
||||
/// A RemoteStore over a flat `path -> text` map.
|
||||
class MemoryRemote implements RemoteStore {
|
||||
/// Creates the store, optionally pre-seeded with [files].
|
||||
MemoryRemote([Map<String, String>? files]) : files = {...?files};
|
||||
|
||||
/// Every file, by full path.
|
||||
final Map<String, String> files;
|
||||
|
||||
/// Set to make every call throw, to test failure handling.
|
||||
Object? failWith;
|
||||
|
||||
void _maybeFail() {
|
||||
final error = failWith;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) async {
|
||||
_maybeFail();
|
||||
final prefix = '$path/';
|
||||
return {
|
||||
for (final key in files.keys)
|
||||
if (key.startsWith(prefix)) key.substring(prefix.length).split('/')[0],
|
||||
}.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) async {
|
||||
_maybeFail();
|
||||
return files[path];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) async {
|
||||
_maybeFail();
|
||||
files[path] = text;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) async {
|
||||
files.remove(path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() async => true;
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/models/nutrition.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/document_store_io.dart';
|
||||
import 'package:diet_guard_app/services/due_slot_check.dart';
|
||||
@ -49,13 +50,10 @@ void main() {
|
||||
// channel, exactly as background_sync_service_test.dart does.
|
||||
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
BudgetHistoryService.resetForTesting(
|
||||
store: FileDocumentStore(tempDir),
|
||||
);
|
||||
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
KuchniaCredentialService.resetForTesting(
|
||||
store: FileDocumentStore(tempDir),
|
||||
);
|
||||
BodyService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
KuchniaCredentialService.resetForTesting(store: FileDocumentStore(tempDir));
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
notificationLog = installFakeAndroidNotifications();
|
||||
@ -71,6 +69,7 @@ void main() {
|
||||
BudgetHistoryService.resetForTesting();
|
||||
KuchniaCredentialService.resetForTesting();
|
||||
MealScheduleService.resetForTesting();
|
||||
BodyService.resetForTesting();
|
||||
NotificationService.resetForTesting();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
@ -114,9 +113,7 @@ void main() {
|
||||
// on specific slots (which depend on the actual time the test runs).
|
||||
await checkAndNotify();
|
||||
expect(
|
||||
notificationLog.where(
|
||||
(c) => c.method == 'show' || c.method == 'cancel',
|
||||
),
|
||||
notificationLog.where((c) => c.method == 'show' || c.method == 'cancel'),
|
||||
isNotEmpty,
|
||||
);
|
||||
});
|
||||
@ -164,10 +161,7 @@ void main() {
|
||||
|
||||
expect(requests, 0);
|
||||
// Still reconciles notifications from the local log alone.
|
||||
expect(
|
||||
notificationLog.where((c) => c.method == 'show'),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(notificationLog.where((c) => c.method == 'show'), isNotEmpty);
|
||||
});
|
||||
|
||||
test('a meal pulled from another device cancels its reminder', () async {
|
||||
|
||||
147
app/test/services/health_steps_test.dart
Normal file
147
app/test/services/health_steps_test.dart
Normal file
@ -0,0 +1,147 @@
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/health_steps.dart';
|
||||
import 'package:diet_guard_app/services/health_steps_source_io.dart';
|
||||
import 'package:diet_guard_app/services/health_steps_source_web.dart' as web;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
|
||||
/// A scripted Health Connect.
|
||||
class FakeStepsSource implements StepsSource {
|
||||
FakeStepsSource({this.granted = true, this.intervals = const []});
|
||||
|
||||
bool granted;
|
||||
bool grantOnRequest = true;
|
||||
List<StepInterval> intervals;
|
||||
Object? failWith;
|
||||
(DateTime, DateTime)? lastRead;
|
||||
|
||||
@override
|
||||
bool get supported => true;
|
||||
|
||||
@override
|
||||
Future<bool> hasPermission() async {
|
||||
final error = failWith;
|
||||
if (error != null) throw error;
|
||||
return granted;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requestPermission() async => granted = grantOnRequest;
|
||||
|
||||
@override
|
||||
Future<List<StepInterval>> read(DateTime from, DateTime to) async {
|
||||
lastRead = (from, to);
|
||||
return intervals;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _at(String iso) => DateTime.parse(iso);
|
||||
|
||||
StepInterval _steps(String from, String to, int n) =>
|
||||
StepInterval(_at(from), _at(to), n);
|
||||
|
||||
void main() {
|
||||
final original = stepsSource;
|
||||
tearDown(() {
|
||||
stepsSource = original;
|
||||
BodyService.resetForTesting();
|
||||
});
|
||||
|
||||
test('intervals inside a workout window are dropped', () {
|
||||
const walk = Session(
|
||||
day: '2026-09-26',
|
||||
kind: 'walk',
|
||||
minutes: 60,
|
||||
km: 5,
|
||||
label: 'walk',
|
||||
source: 'screen-locker',
|
||||
start: '2026-09-26T18:00:00',
|
||||
end: '2026-09-26T19:00:00',
|
||||
);
|
||||
const noWindow = Session(
|
||||
day: '2026-09-26',
|
||||
kind: 'strength',
|
||||
minutes: 60,
|
||||
km: null,
|
||||
label: 'lift',
|
||||
source: 'screen-locker',
|
||||
);
|
||||
final counts = stepsOutsideWorkouts(
|
||||
[
|
||||
_steps('2026-09-26T08:00:00', '2026-09-26T09:00:00', 3000),
|
||||
_steps('2026-09-26T18:30:00', '2026-09-26T18:45:00', 2000),
|
||||
_steps('2026-09-26T17:50:00', '2026-09-26T18:05:00', 500),
|
||||
_steps('2026-09-26T19:00:00', '2026-09-26T19:10:00', 400),
|
||||
_steps('2026-09-27T10:00:00', '2026-09-27T11:00:00', 7000),
|
||||
],
|
||||
[walk, noWindow],
|
||||
);
|
||||
expect(counts, {'2026-09-26': 3400, '2026-09-27': 7000});
|
||||
});
|
||||
|
||||
test('session start/end parse tolerantly from the wire', () {
|
||||
final parsed = Session.fromWire({
|
||||
'day': '2026-09-26',
|
||||
'kind': 'walk',
|
||||
'min': 60,
|
||||
'start': '2026-09-26T18:00:00+02:00',
|
||||
'end': 'soon',
|
||||
})!;
|
||||
expect(parsed.start, '2026-09-26T18:00:00+02:00');
|
||||
expect(parsed.end, isNull);
|
||||
});
|
||||
|
||||
test('applySteps re-stamps only changed days', () async {
|
||||
final body = await BodyService.initForTesting(MemoryDocStore());
|
||||
final t0 = _at('2026-09-27T08:00:00');
|
||||
expect(await applySteps(body, {'2026-09-26': 100}, now: t0), isTrue);
|
||||
final stamp = body.document.steps['2026-09-26']!.t;
|
||||
final later = t0.add(const Duration(hours: 1));
|
||||
expect(await applySteps(body, {'2026-09-26': 100}, now: later), isFalse);
|
||||
expect(body.document.steps['2026-09-26']!.t, stamp);
|
||||
await applySteps(body, {'2026-09-26': 150}, now: later);
|
||||
expect(body.document.steps['2026-09-26']!.n, 150);
|
||||
});
|
||||
|
||||
test('refreshSteps reads 28 days when permitted, never prompts', () async {
|
||||
await BodyService.initForTesting(MemoryDocStore());
|
||||
final fake = FakeStepsSource(
|
||||
granted: false,
|
||||
intervals: [_steps('2026-09-26T08:00:00', '2026-09-26T09:00:00', 42)],
|
||||
);
|
||||
stepsSource = fake;
|
||||
final now = _at('2026-09-28T12:00:00');
|
||||
expect(await refreshSteps(now: now), isFalse);
|
||||
expect(fake.lastRead, isNull);
|
||||
fake.granted = true;
|
||||
expect(await refreshSteps(now: now), isTrue);
|
||||
expect(fake.lastRead!.$1, DateTime(2026, 8, 31));
|
||||
expect(BodyService.instance.document.steps['2026-09-26']!.n, 42);
|
||||
fake.failWith = StateError('Health Connect gone');
|
||||
expect(await refreshSteps(now: now), isFalse);
|
||||
});
|
||||
|
||||
test('refreshSteps no-ops without the body service', () async {
|
||||
stepsSource = FakeStepsSource();
|
||||
expect(await refreshSteps(), isFalse);
|
||||
});
|
||||
|
||||
test('platform sources: web and non-Android report unsupported', () async {
|
||||
const source = web.NoStepsSource();
|
||||
expect(source.supported, isFalse);
|
||||
expect(await source.hasPermission(), isFalse);
|
||||
expect(await source.requestPermission(), isFalse);
|
||||
expect(await source.read(DateTime(2026), DateTime(2026, 2)), isEmpty);
|
||||
expect(web.createStepsSource().supported, isFalse);
|
||||
expect(createStepsSource().supported, isFalse);
|
||||
expect(HealthConnectStepsSource().supported, isFalse);
|
||||
stepsSource = HealthConnectStepsSource();
|
||||
await BodyService.initForTesting(MemoryDocStore());
|
||||
expect(await refreshSteps(), isFalse);
|
||||
expect(BodyService.instance.document.steps, isEmpty);
|
||||
expect(const StepsEntry(n: 1, t: 'x').toJson(), {'n': 1, 't': 'x'});
|
||||
});
|
||||
}
|
||||
69
app/test/services/sync_body_test.dart
Normal file
69
app/test/services/sync_body_test.dart
Normal file
@ -0,0 +1,69 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_body.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_body.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
|
||||
const _dir = 'diet-guard-sync/devices';
|
||||
|
||||
void main() {
|
||||
tearDown(BodyService.resetForTesting);
|
||||
|
||||
test('no-ops when the body service is not initialised', () async {
|
||||
final remote = MemoryRemote();
|
||||
await syncBody(remote, devicesDir: _dir);
|
||||
expect(remote.files, isEmpty);
|
||||
});
|
||||
|
||||
test('merges a peer, ingests the weigh-in, pushes the result', () async {
|
||||
await BodyService.initForTesting(MemoryDocStore());
|
||||
await BodyService.instance.setProfile(
|
||||
heightCm: 180,
|
||||
now: DateTime.parse('2026-09-01T10:00:00'),
|
||||
);
|
||||
final peer = BodyDocument(
|
||||
profile: const {
|
||||
profileBirth: Stamped('1995-03-01', '2026-09-02T10:00:00+02:00'),
|
||||
},
|
||||
activity: const ActivityData(
|
||||
sessions: [
|
||||
{'day': '2026-09-26', 'kind': 'strength', 'min': 80},
|
||||
],
|
||||
t: '2026-09-27T10:00:00+02:00',
|
||||
),
|
||||
);
|
||||
final remote = MemoryRemote({
|
||||
'$_dir/pc-uuid/body.json': encodeBodyForPush(bodyToLog(peer)),
|
||||
'wake-alarm-sync/devices/phone/alarm.json': jsonEncode({
|
||||
'alarm': {
|
||||
'fields': {
|
||||
'morning_sessions': [
|
||||
[
|
||||
{'date': '2026-09-27', 'weight_kg': 80.5},
|
||||
],
|
||||
'2026-09-27T05:00:00.000Z-0000-phone',
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await syncBody(remote, devicesDir: _dir);
|
||||
|
||||
final body = BodyService.instance;
|
||||
expect(body.birth, '1995-03-01');
|
||||
expect(body.heightCm, 180);
|
||||
expect(body.latestWeight, ('2026-09-27', 80.5));
|
||||
expect(body.sessions, hasLength(1));
|
||||
final pushed = remote.files['$_dir/$currentSyncDeviceId/body.json'];
|
||||
expect(pushed, isNotNull);
|
||||
final pushedDoc = logToBody(parseRemoteBody(pushed!));
|
||||
expect(pushedDoc.weights.keys, ['2026-09-27']);
|
||||
expect(pushedDoc.profile.keys.toSet(), {profileBirth, profileHeight});
|
||||
});
|
||||
}
|
||||
216
app/test/services/sync_merge_body_test.dart
Normal file
216
app/test/services/sync_merge_body_test.dart
Normal file
@ -0,0 +1,216 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_body.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _sessions = [
|
||||
{'day': '2026-09-26', 'kind': 'run', 'min': 30, 'km': 5},
|
||||
];
|
||||
|
||||
BodyDocument _doc({
|
||||
String heightT = '2026-09-01T10:00:00+02:00',
|
||||
String birthT = '2026-09-01T10:00:00+02:00',
|
||||
double height = 180,
|
||||
String birth = '1995-03-01',
|
||||
Map<String, WeightEntry> weights = const {},
|
||||
ActivityData? activity,
|
||||
}) => BodyDocument(
|
||||
profile: {
|
||||
profileHeight: Stamped(height, heightT),
|
||||
profileBirth: Stamped(birth, birthT),
|
||||
profileSex: const Stamped('m', '2026-09-01T10:00:00+02:00'),
|
||||
},
|
||||
weights: weights,
|
||||
activity: activity,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('round-trips a document through the wire format', () {
|
||||
final doc = _doc(
|
||||
weights: const {
|
||||
'2026-09-26': WeightEntry(
|
||||
kg: 81.2,
|
||||
src: 'phone',
|
||||
t: '2026-09-26T07:12:03+02:00',
|
||||
),
|
||||
'2026-09-27': WeightEntry(
|
||||
kg: null,
|
||||
src: 'manual',
|
||||
t: '2026-09-27T09:00:00+02:00',
|
||||
),
|
||||
},
|
||||
activity: const ActivityData(
|
||||
sessions: _sessions,
|
||||
t: '2026-09-27T10:00:00+02:00',
|
||||
),
|
||||
);
|
||||
final wire = encodeBodyForPush(bodyToLog(doc));
|
||||
final back = logToBody(parseRemoteBody(wire));
|
||||
expect(back.profile[profileHeight]!.value, 180.0);
|
||||
expect(back.profile[profileBirth]!.value, '1995-03-01');
|
||||
expect(back.profile[profileSex]!.value, 'm');
|
||||
expect(
|
||||
stampMillis(back.profile[profileHeight]!.t),
|
||||
stampMillis('2026-09-01T10:00:00+02:00'),
|
||||
);
|
||||
expect(back.weights['2026-09-26']!.kg, 81.2);
|
||||
expect(back.weights['2026-09-26']!.src, 'phone');
|
||||
expect(back.weights['2026-09-27']!.kg, isNull);
|
||||
expect(back.activity!.sessions, _sessions);
|
||||
// Re-deriving from the round-tripped stamps gives the same clocks, so
|
||||
// an unchanged document re-pushes byte-identical text. Compared as
|
||||
// text: Record equality is shallow over map/list values.
|
||||
expect(encodeBodyForPush(bodyToLog(back)), wire);
|
||||
});
|
||||
|
||||
test('profile fields merge independently, last writer wins each', () {
|
||||
final phone = _doc(height: 181, heightT: '2026-09-20T10:00:00+02:00');
|
||||
final pc = _doc(birth: '1994-03-01', birthT: '2026-09-21T10:00:00+02:00');
|
||||
final merged = logToBody(mergeLogs(bodyToLog(phone), bodyToLog(pc)));
|
||||
expect(merged.profile[profileHeight]!.value, 181.0);
|
||||
expect(merged.profile[profileBirth]!.value, '1994-03-01');
|
||||
});
|
||||
|
||||
test('an empty document is an empty log', () {
|
||||
expect(bodyToLog(const BodyDocument()), isEmpty);
|
||||
final back = logToBody(const {});
|
||||
expect(back.profile, isEmpty);
|
||||
expect(back.activity, isNull);
|
||||
});
|
||||
|
||||
test('malformed wire parts are skipped', () {
|
||||
final hlc = Hlc.newTick('n', wallTimeMsOverride: 1000);
|
||||
final log = <String, Record>{
|
||||
'profile': Record(
|
||||
id: 'profile',
|
||||
fields: {'height_cm': ('tall', hlc), 'birth': (3, hlc)},
|
||||
),
|
||||
'w:bad': Record(
|
||||
id: 'w:bad',
|
||||
fields: {
|
||||
'kg': ({'kg': 80}, hlc),
|
||||
},
|
||||
),
|
||||
'w:2026-01-01': Record(id: 'w:2026-01-01', fields: const {}),
|
||||
'w:2026-01-02': Record(
|
||||
id: 'w:2026-01-02',
|
||||
fields: {
|
||||
'kg': ({'kg': 1000}, hlc),
|
||||
},
|
||||
),
|
||||
'w:2026-01-03': Record(
|
||||
id: 'w:2026-01-03',
|
||||
fields: {
|
||||
'kg': ({'kg': 80}, hlc),
|
||||
},
|
||||
deleted: true,
|
||||
),
|
||||
'bf:bad': Record(
|
||||
id: 'bf:bad',
|
||||
fields: {
|
||||
'pct': ({'pct': 20}, hlc),
|
||||
},
|
||||
),
|
||||
'bf:2026-01-01': Record(id: 'bf:2026-01-01', fields: const {}),
|
||||
'bf:2026-01-02': Record(
|
||||
id: 'bf:2026-01-02',
|
||||
fields: {
|
||||
'pct': ({'pct': 90}, hlc),
|
||||
},
|
||||
),
|
||||
'bf:2026-01-03': Record(
|
||||
id: 'bf:2026-01-03',
|
||||
fields: {
|
||||
'pct': ({'pct': 20}, hlc),
|
||||
},
|
||||
deleted: true,
|
||||
),
|
||||
'st:bad': Record(
|
||||
id: 'st:bad',
|
||||
fields: {
|
||||
'n': ({'n': 5}, hlc),
|
||||
},
|
||||
),
|
||||
'st:2026-01-01': Record(id: 'st:2026-01-01', fields: const {}),
|
||||
'st:2026-01-02': Record(
|
||||
id: 'st:2026-01-02',
|
||||
fields: {
|
||||
'n': ({'n': -1}, hlc),
|
||||
},
|
||||
),
|
||||
'st:2026-01-03': Record(
|
||||
id: 'st:2026-01-03',
|
||||
fields: {
|
||||
'n': ({'n': 5}, hlc),
|
||||
},
|
||||
deleted: true,
|
||||
),
|
||||
'activity': Record(id: 'activity', fields: {'sessions': ('x', hlc)}),
|
||||
};
|
||||
final doc = logToBody(log);
|
||||
// Profile fields are relayed as merged; the service validates on read.
|
||||
expect(doc.profile.keys.toSet(), {'height_cm', 'birth'});
|
||||
expect(doc.weights, isEmpty);
|
||||
expect(doc.bodyFat, isEmpty);
|
||||
expect(doc.steps, isEmpty);
|
||||
expect(doc.activity, isNull);
|
||||
});
|
||||
|
||||
test('body fat, steps and the goal round-trip; goal keeps its own stamp', () {
|
||||
const doc = BodyDocument(
|
||||
profile: {
|
||||
profileHeight: Stamped(180, '2026-09-01T10:00:00+02:00'),
|
||||
'goal': Stamped({'dir': 'gain'}, '2026-09-05T10:00:00+02:00'),
|
||||
},
|
||||
steps: {
|
||||
'2026-09-26': StepsEntry(n: 9123, t: '2026-09-26T21:00:00+02:00'),
|
||||
},
|
||||
bodyFat: {
|
||||
'2026-09-26': BodyFatEntry(pct: 18.5, t: '2026-09-26T08:00:00+02:00'),
|
||||
'2026-09-27': BodyFatEntry(pct: null, t: '2026-09-27T08:00:00+02:00'),
|
||||
},
|
||||
);
|
||||
final wire = encodeBodyForPush(bodyToLog(doc));
|
||||
final raw = jsonDecode(wire) as Map<String, dynamic>;
|
||||
expect(
|
||||
(raw['bf:2026-09-26'] as Map)['fields'],
|
||||
containsPair('pct', [
|
||||
{'pct': 18.5},
|
||||
isA<String>(),
|
||||
]),
|
||||
);
|
||||
final back = logToBody(parseRemoteBody(wire));
|
||||
expect(back.bodyFat['2026-09-26']!.pct, 18.5);
|
||||
expect(back.bodyFat['2026-09-27']!.pct, isNull);
|
||||
expect(back.profile['goal']!.value, {'dir': 'gain'});
|
||||
expect(back.steps['2026-09-26']!.n, 9123);
|
||||
expect(
|
||||
(raw['st:2026-09-26'] as Map)['fields'],
|
||||
containsPair('n', [
|
||||
{'n': 9123},
|
||||
isA<String>(),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
stampMillis(back.profile['goal']!.t),
|
||||
isNot(stampMillis(back.profile[profileHeight]!.t)),
|
||||
);
|
||||
expect(encodeBodyForPush(bodyToLog(back)), wire);
|
||||
});
|
||||
|
||||
test('stamps keep milliseconds only when the clock has them', () {
|
||||
expect(
|
||||
stampFromHlc(Hlc.newTick('n', wallTimeMsOverride: 1500)),
|
||||
'1970-01-01T00:00:01.500Z',
|
||||
);
|
||||
final whole = stampFromHlc(Hlc.newTick('n', wallTimeMsOverride: 2000));
|
||||
expect(stampMillis(whole), 2000);
|
||||
expect(whole, isNot(contains('.')));
|
||||
});
|
||||
|
||||
test('a non-object payload is rejected', () {
|
||||
expect(() => parseRemoteBody('[]'), throwsFormatException);
|
||||
});
|
||||
}
|
||||
134
app/test/services/wake_alarm_weights_test.dart
Normal file
134
app/test/services/wake_alarm_weights_test.dart
Normal file
@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/body_document.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/wake_alarm_weights.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
|
||||
String _hlc(String iso) => Hlc.newTick(
|
||||
'phone-uuid',
|
||||
wallTimeMsOverride: DateTime.parse(iso).millisecondsSinceEpoch,
|
||||
).toStr();
|
||||
|
||||
String _alarm({Object? latest, String? latestHlc, Object? sessions}) =>
|
||||
jsonEncode({
|
||||
'alarm': {
|
||||
'id': 'alarm',
|
||||
'deleted': false,
|
||||
'fields': {
|
||||
'latest_weight_kg': ?(latest == null
|
||||
? null
|
||||
: [latest, latestHlc ?? _hlc('2026-09-27T05:30:00Z')]),
|
||||
'morning_sessions': ?(sessions == null
|
||||
? null
|
||||
: [sessions, _hlc('2026-09-27T05:31:00Z')]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
test('latest weigh-in is stamped by its own clock', () {
|
||||
final out = wakeAlarmCandidates([
|
||||
('phone', _alarm(latest: {'date': '2026-09-27', 'kg': 80.4})),
|
||||
]);
|
||||
expect(out['2026-09-27']!.kg, 80.4);
|
||||
expect(out['2026-09-27']!.src, 'phone');
|
||||
expect(
|
||||
stampMillis(out['2026-09-27']!.t),
|
||||
DateTime.parse('2026-09-27T05:30:00Z').millisecondsSinceEpoch,
|
||||
);
|
||||
});
|
||||
|
||||
test('sessions fill gaps at midnight and lose to the latest field', () {
|
||||
final out = wakeAlarmCandidates([
|
||||
(
|
||||
'phone',
|
||||
_alarm(
|
||||
latest: {'date': '2026-09-27', 'kg': 80.4},
|
||||
sessions: [
|
||||
{'date': '2026-09-27', 'weight_kg': 99},
|
||||
{'date': '2026-09-26', 'weight_kg': 81},
|
||||
{'date': '2026-09-25', 'weight_kg': null},
|
||||
{'date': '2026-09-24', 'weight_kg': 5},
|
||||
{'date': 'monday', 'weight_kg': 81},
|
||||
'junk',
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
expect(out.keys.toSet(), {'2026-09-27', '2026-09-26'});
|
||||
expect(out['2026-09-27']!.kg, 80.4);
|
||||
expect(out['2026-09-26']!.t, '2026-09-26T00:00:00+00:00');
|
||||
});
|
||||
|
||||
test('the newest stamp per day wins across devices', () {
|
||||
final out = wakeAlarmCandidates([
|
||||
(
|
||||
'a',
|
||||
_alarm(
|
||||
latest: {'date': '2026-09-27', 'kg': 80},
|
||||
latestHlc: _hlc('2026-09-27T05:00:00Z'),
|
||||
),
|
||||
),
|
||||
(
|
||||
'b',
|
||||
_alarm(
|
||||
latest: {'date': '2026-09-27', 'kg': 79},
|
||||
latestHlc: _hlc('2026-09-27T06:00:00Z'),
|
||||
),
|
||||
),
|
||||
(
|
||||
'c',
|
||||
_alarm(
|
||||
latest: {'date': '2026-09-27', 'kg': 78},
|
||||
latestHlc: _hlc('2026-09-27T04:00:00Z'),
|
||||
),
|
||||
),
|
||||
]);
|
||||
expect(out['2026-09-27']!.kg, 79);
|
||||
});
|
||||
|
||||
test('unusable devices are skipped', () {
|
||||
final out = wakeAlarmCandidates([
|
||||
('bad-json', '{nope'),
|
||||
('not-map', '[]'),
|
||||
('no-fields', jsonEncode({'alarm': {}})),
|
||||
(
|
||||
'bad-hlc',
|
||||
_alarm(latest: {'date': '2026-09-27', 'kg': 80}, latestHlc: 'x'),
|
||||
),
|
||||
('bad-sessions', _alarm(sessions: 'x')),
|
||||
('bool-kg', _alarm(latest: {'date': '2026-09-27', 'kg': true})),
|
||||
]);
|
||||
expect(out, isEmpty);
|
||||
});
|
||||
|
||||
group('ingestWakeAlarmWeights', () {
|
||||
tearDown(BodyService.resetForTesting);
|
||||
|
||||
test('no-ops when the body service is not initialised', () async {
|
||||
expect(await ingestWakeAlarmWeights(MemoryRemote()), isFalse);
|
||||
});
|
||||
|
||||
test('applies every device\'s weigh-ins', () async {
|
||||
await BodyService.initForTesting(MemoryDocStore());
|
||||
final remote = MemoryRemote({
|
||||
'$wakeAlarmDevicesDir/phone-uuid/alarm.json': _alarm(
|
||||
latest: {'date': '2026-09-27', 'kg': 80.4},
|
||||
),
|
||||
'$wakeAlarmDevicesDir/other/state.json': '{}',
|
||||
});
|
||||
expect(await ingestWakeAlarmWeights(remote), isTrue);
|
||||
expect(BodyService.instance.latestWeight, ('2026-09-27', 80.4));
|
||||
});
|
||||
|
||||
test('never throws out of the sync tick', () async {
|
||||
await BodyService.initForTesting(MemoryDocStore());
|
||||
final remote = MemoryRemote()..failWith = RemoteSyncError('offline');
|
||||
expect(await ingestWakeAlarmWeights(remote), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
173
app/test/widgets/body/body_widgets_test.dart
Normal file
173
app/test/widgets/body/body_widgets_test.dart
Normal file
@ -0,0 +1,173 @@
|
||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/services/week_plan.dart';
|
||||
import 'package:diet_guard_app/widgets/body/activity_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_stats_cards.dart';
|
||||
import 'package:diet_guard_app/widgets/body/week_plan_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/weight_chart.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Widget child) async {
|
||||
tester.view.physicalSize = const Size(1200, 4000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
const _short = Biometrics(
|
||||
weightKg: 50,
|
||||
heightCm: 150,
|
||||
ageYears: 17,
|
||||
isMale: false,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('helpers', () {
|
||||
test('halfUp rounds half away from banker', () {
|
||||
expect(halfUp(2.5), 3);
|
||||
expect(halfUp(0.125, 2), 0.13);
|
||||
});
|
||||
|
||||
test('ageOn counts completed years', () {
|
||||
final birth = DateTime(1995, 3, 1);
|
||||
expect(ageOn(birth, DateTime(2026, 2, 28)), 30);
|
||||
expect(ageOn(birth, DateTime(2026, 3, 1)), 31);
|
||||
expect(ageOn(birth, DateTime(2026, 3, 2)), 31);
|
||||
expect(ageOn(DateTime(1995, 3, 15), DateTime(2026, 3, 2)), 30);
|
||||
});
|
||||
|
||||
test('weekStart is the local Monday', () {
|
||||
expect(weekStart(DateTime(2026, 10, 4, 18)), DateTime(2026, 9, 28));
|
||||
});
|
||||
|
||||
test('an unknown session kind uses the generic MET', () {
|
||||
const s = Session(
|
||||
day: '2026-09-27',
|
||||
kind: 'yoga',
|
||||
minutes: 60,
|
||||
km: null,
|
||||
label: 'yoga',
|
||||
source: '',
|
||||
);
|
||||
expect(metFor(s), 4);
|
||||
});
|
||||
|
||||
test('seriesInRange keeps the last N days', () {
|
||||
final series = [('2026-01-01', 80.0), ('2026-09-20', 79.0)];
|
||||
final today = DateTime(2026, 9, 27);
|
||||
expect(seriesInRange(series, 30, today), [('2026-09-20', 79.0)]);
|
||||
expect(seriesInRange(series, null, today), series);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a short teenager sees n/a formulas and the age note', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
const Column(
|
||||
children: [BmiCard(bio: _short), IdealWeightCard(bio: _short)],
|
||||
),
|
||||
);
|
||||
expect(find.text('n/a'), findsNWidgets(4));
|
||||
expect(find.textContaining('undefined below 152.4 cm'), findsOneWidget);
|
||||
expect(find.text('adult categories do not apply under 20'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('activity card states', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
const ActivityCard(sessions: [], weightKg: null, exercise: null),
|
||||
);
|
||||
expect(find.textContaining('Enter your weight'), findsOneWidget);
|
||||
await _pump(
|
||||
tester,
|
||||
const ActivityCard(
|
||||
sessions: [],
|
||||
weightKg: 80,
|
||||
exercise: {'MET': 0, 'ACSM': 0, 'Per-km': 0},
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('No workouts in the last 28 days'), findsOne);
|
||||
expect(find.text('0 kcal/day'), findsNWidgets(3));
|
||||
var tapped = false;
|
||||
await _pump(
|
||||
tester,
|
||||
ActivityCard(
|
||||
sessions: null,
|
||||
weightKg: null,
|
||||
exercise: null,
|
||||
steps: const {'2026-09-26': 3000, '2026-09-27': 12000},
|
||||
stepsKcal: 20,
|
||||
onConnectSteps: () => tapped = true,
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('No activity data yet'), findsNothing);
|
||||
expect(
|
||||
find.textContaining('12000 on 2026-09-27; above 4000/day they add 20'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Connect Health Connect steps'));
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the chart plots two weigh-ins and switches range', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
WeightChart(
|
||||
series: const [('2026-06-01', 82.0), ('2026-09-20', 79.5)],
|
||||
today: DateTime(2026, 9, 27),
|
||||
),
|
||||
);
|
||||
expect(find.text('Need two readings for a graph.'), findsOneWidget);
|
||||
await tester.tap(find.text('All'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(LineChart), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the week planner fills logged days and honours typing', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
WeekPlanCard(
|
||||
defaultTarget: 2000,
|
||||
logged: const {'2026-09-28': 2500},
|
||||
today: DateTime(2026, 9, 30),
|
||||
),
|
||||
);
|
||||
// Mon logged, Tue unlogged: (14000 - 2500) / 6 = 1917.
|
||||
expect(find.textContaining('2500 kcal (logged)'), findsOneWidget);
|
||||
expect(find.textContaining('(not logged -- type it)'), findsOneWidget);
|
||||
expect(find.textContaining('Eat 1917 kcal'), findsOneWidget);
|
||||
|
||||
final dayFields = find.widgetWithText(TextField, 'ate…');
|
||||
await tester.enterText(dayFields.at(1), '1800');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('1800 kcal (typed)'), findsOneWidget);
|
||||
expect(find.textContaining('Eat 1940 kcal'), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Weekly average target (kcal/day)'),
|
||||
'900',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('below the 1200 kcal floor'), findsOneWidget);
|
||||
|
||||
for (var i = 0; i < 7; i++) {
|
||||
await tester.enterText(dayFields.at(i), '2000');
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Every day is known'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
222
diet_guard/_activity_kcal.py
Normal file
222
diet_guard/_activity_kcal.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""Exercise energy per workout, by every formula the workout data supports.
|
||||
|
||||
Pure arithmetic over :class:`Session` values; where sessions come from is
|
||||
:mod:`diet_guard._activity_sources`. Mirrored in
|
||||
``app/lib/services/activity_kcal.dart`` and gated by the shared
|
||||
``tests/fixtures/body_calc.json``.
|
||||
|
||||
Every formula returns **net** kcal -- energy above resting. The measured
|
||||
TDEE adds exercise on top of ``BMR x 1.2``, which already pays for the resting
|
||||
hour, so a gross figure would count it twice.
|
||||
|
||||
* **MET** (Ainsworth 2011 Compendium): ``(MET - 1) x kg x hours``. Running and
|
||||
walking read MET off the Compendium's speed table (linear between rows);
|
||||
lifting is code 02052 (resistance training, squats) = 5.0 -- between
|
||||
02054 (multiple exercises, 8-15 reps, 3.5) and 02050 (power lifting,
|
||||
vigorous, 6.0), since a 5x5 session is mostly rest between heavy sets.
|
||||
* **ACSM** metabolic equations, level ground: VO2 = 0.2 v + 3.5 (running) or
|
||||
0.1 v + 3.5 (walking), v in m/min; net kcal = (VO2 - 3.5) x kg x min x 5/1000.
|
||||
* **Per-km**: running ~0.9 kcal/kg/km net (the classic "1 kcal/kg/km" is
|
||||
gross), walking ~0.5 kcal/kg/km net.
|
||||
|
||||
ACSM and per-km need a distance, so they only exist for runs and walks that
|
||||
carry one; every other session falls back to its MET figure (``fallback``) so
|
||||
all three daily totals cover the same workouts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
import itertools
|
||||
|
||||
__all__ = [
|
||||
"FORMULAS",
|
||||
"METHODS_NOTE",
|
||||
"WINDOW_DAYS",
|
||||
"Session",
|
||||
"SessionKcal",
|
||||
"daily_exercise",
|
||||
"met_for",
|
||||
"session_kcal",
|
||||
"window_days",
|
||||
]
|
||||
|
||||
FORMULAS: tuple[str, ...] = ("MET", "ACSM", "Per-km")
|
||||
# Shown under the Activity panel on both surfaces.
|
||||
METHODS_NOTE = (
|
||||
"MET = standard activity values x your weight x time. "
|
||||
"ACSM = American College of Sports Medicine running/walking equation from "
|
||||
"your speed. Per-km = ~0.9 kcal per kg per km running, ~0.5 walking. "
|
||||
"All are energy above resting."
|
||||
)
|
||||
# Measured activity averages the last two full weeks.
|
||||
WINDOW_DAYS = 14
|
||||
|
||||
_RUN_MET_BY_KMH: tuple[tuple[float, float], ...] = (
|
||||
(6.4, 6.0),
|
||||
(8.0, 8.3),
|
||||
(8.4, 9.0),
|
||||
(9.7, 9.8),
|
||||
(10.8, 10.5),
|
||||
(11.3, 11.0),
|
||||
(12.1, 11.5),
|
||||
(12.9, 11.8),
|
||||
(13.8, 12.3),
|
||||
(14.5, 12.8),
|
||||
(16.1, 14.5),
|
||||
(17.7, 16.0),
|
||||
(19.3, 19.0),
|
||||
(20.9, 19.8),
|
||||
(22.5, 23.0),
|
||||
)
|
||||
_WALK_MET_BY_KMH: tuple[tuple[float, float], ...] = (
|
||||
(3.2, 2.8),
|
||||
(4.0, 3.0),
|
||||
(4.8, 3.5),
|
||||
(5.6, 4.3),
|
||||
(6.4, 5.0),
|
||||
(7.2, 7.0),
|
||||
(8.0, 8.3),
|
||||
)
|
||||
# Compendium codes: 02052 resistance training, 12150 running general,
|
||||
# 17200 walking general-ish, 01015 bicycling general, generic moderate.
|
||||
_FIXED_MET = {"strength": 5.0, "run": 8.0, "walk": 3.5, "cycle": 7.5, "other": 4.0}
|
||||
_ACSM_SLOPE = {"run": 0.2, "walk": 0.1}
|
||||
_NET_KCAL_PER_KG_KM = {"run": 0.9, "walk": 0.5}
|
||||
_KCAL_PER_LITRE_O2 = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
"""One workout, reduced to what the energy formulas need.
|
||||
|
||||
Attributes:
|
||||
day: Local ``YYYY-MM-DD`` the workout happened on.
|
||||
kind: ``strength``, ``run``, ``walk``, ``cycle`` or ``other``.
|
||||
minutes: Duration.
|
||||
km: Distance, or None when the source has none.
|
||||
label: Human description for the list.
|
||||
source: Where it came from (``runnerup``, ``screen-locker``).
|
||||
start: Local ISO start time, when the source knows it.
|
||||
end: Local ISO end time, when the source knows it. The phone drops
|
||||
Health Connect steps inside ``start..end`` so a walk is not
|
||||
counted as both a workout and steps.
|
||||
"""
|
||||
|
||||
day: str
|
||||
kind: str
|
||||
minutes: float
|
||||
km: float | None
|
||||
label: str
|
||||
source: str
|
||||
start: str | None = None
|
||||
end: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionKcal:
|
||||
"""Net kcal of one session per formula; ``fallback`` names MET stand-ins."""
|
||||
|
||||
session: Session
|
||||
kcal: dict[str, float]
|
||||
fallback: frozenset[str]
|
||||
|
||||
|
||||
def _interpolate(table: tuple[tuple[float, float], ...], kmh: float) -> float:
|
||||
"""Linear interpolation of MET at ``kmh``, clamped to the table's ends."""
|
||||
if kmh <= table[0][0]:
|
||||
return table[0][1]
|
||||
for (x0, y0), (x1, y1) in itertools.pairwise(table):
|
||||
if kmh <= x1:
|
||||
return y0 + (y1 - y0) * (kmh - x0) / (x1 - x0)
|
||||
return table[-1][1]
|
||||
|
||||
|
||||
def _speed_kmh(session: Session) -> float | None:
|
||||
"""Average speed, or None without a usable distance and duration."""
|
||||
if session.km is None or session.km <= 0 or session.minutes <= 0:
|
||||
return None
|
||||
return session.km / (session.minutes / 60.0)
|
||||
|
||||
|
||||
def met_for(session: Session) -> float:
|
||||
"""The MET value for ``session``: speed-based when possible, else fixed."""
|
||||
kmh = _speed_kmh(session)
|
||||
if kmh is not None and session.kind == "run":
|
||||
return _interpolate(_RUN_MET_BY_KMH, kmh)
|
||||
if kmh is not None and session.kind == "walk":
|
||||
return _interpolate(_WALK_MET_BY_KMH, kmh)
|
||||
return _FIXED_MET.get(session.kind, _FIXED_MET["other"])
|
||||
|
||||
|
||||
def session_kcal(session: Session, weight_kg: float) -> SessionKcal:
|
||||
"""Net kcal of ``session`` for a ``weight_kg`` person, by every formula."""
|
||||
hours = session.minutes / 60.0
|
||||
met = (met_for(session) - 1.0) * weight_kg * hours
|
||||
kcal = {"MET": met, "ACSM": met, "Per-km": met}
|
||||
fallback = {"ACSM", "Per-km"}
|
||||
kmh = _speed_kmh(session)
|
||||
if kmh is not None and session.km is not None and session.kind in _ACSM_SLOPE:
|
||||
metres_per_min = kmh * 1000.0 / 60.0
|
||||
net_vo2 = _ACSM_SLOPE[session.kind] * metres_per_min
|
||||
kcal["ACSM"] = net_vo2 * weight_kg * session.minutes * _KCAL_PER_LITRE_O2 / 1000
|
||||
kcal["Per-km"] = _NET_KCAL_PER_KG_KM[session.kind] * weight_kg * session.km
|
||||
fallback.clear()
|
||||
return SessionKcal(session=session, kcal=kcal, fallback=frozenset(fallback))
|
||||
|
||||
|
||||
def daily_exercise(
|
||||
sessions: tuple[Session, ...],
|
||||
weight_kg: float,
|
||||
today: date,
|
||||
extra_by_day: dict[str, float] | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Mean net exercise kcal per day over the last :data:`WINDOW_DAYS`.
|
||||
|
||||
Only full past days count: today is still in progress, so the window
|
||||
ends yesterday. It starts :data:`WINDOW_DAYS` days back -- or, while the
|
||||
data is younger than that, on the first day that has any, so a week of
|
||||
history is averaged over that week rather than diluted over 14 days. The
|
||||
denominator is calendar days within that span: a rest day burns no
|
||||
exercise kcal, which is exactly what a daily average must reflect. With
|
||||
no past data at all, every formula reads 0.
|
||||
|
||||
``extra_by_day`` (day -> net kcal, e.g. steps outside workouts) is added
|
||||
to every formula alike: it is not a workout any formula could measure.
|
||||
"""
|
||||
extra = extra_by_day or {}
|
||||
span = window_days(sessions, today, extra)
|
||||
totals = dict.fromkeys(FORMULAS, 0.0)
|
||||
if not span:
|
||||
return totals
|
||||
first = (today - timedelta(days=span)).isoformat()
|
||||
last = today.isoformat()
|
||||
for session in sessions:
|
||||
if first <= session.day < last:
|
||||
for name, value in session_kcal(session, weight_kg).kcal.items():
|
||||
totals[name] += value
|
||||
for day, kcal in extra.items():
|
||||
if first <= day < last:
|
||||
for name in totals:
|
||||
totals[name] += kcal
|
||||
return {name: total / span for name, total in totals.items()}
|
||||
|
||||
|
||||
def window_days(
|
||||
sessions: tuple[Session, ...],
|
||||
today: date,
|
||||
extra_by_day: dict[str, float] | None = None,
|
||||
) -> int:
|
||||
"""How many full past days :func:`daily_exercise` averages over.
|
||||
|
||||
:data:`WINDOW_DAYS`, or fewer while the earliest past data (a session or
|
||||
an ``extra_by_day`` key before ``today``) is younger than that; 0 when
|
||||
there is no past data at all.
|
||||
"""
|
||||
last = today.isoformat()
|
||||
days = [s.day for s in sessions if s.day < last]
|
||||
days += [d for d in (extra_by_day or {}) if d < last]
|
||||
if not days:
|
||||
return 0
|
||||
return min((today - date.fromisoformat(min(days))).days, WINDOW_DAYS)
|
||||
205
diet_guard/_activity_sources.py
Normal file
205
diet_guard/_activity_sources.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""Collect the last weeks' workouts from RunnerUp and screen-locker.
|
||||
|
||||
PC-only: both sources live on this machine. The result is published into
|
||||
the Body document (``_body_store.set_activity``) so the phone shows the same
|
||||
sessions. Dedup rules, so one workout counts once (``docs/DOCS-body.md``):
|
||||
|
||||
* RunnerUp TCX files are the source for runs; screen-locker's
|
||||
``runnerup_verified`` entry for a day is used only when no TCX exists for it.
|
||||
* ``phone_verified`` is the phone's report of the same StrongLifts session the
|
||||
PC logs as ``pc_workout_verified``; it counts only on a day without one.
|
||||
|
||||
Both sources are optional and read defensively: a missing ``screen_locker``
|
||||
package, an unreadable log, or a malformed TCX drops that source (or file),
|
||||
never the tick.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from importlib import import_module
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
from defusedxml.ElementTree import parse as parse_xml
|
||||
|
||||
from diet_guard._activity_kcal import Session
|
||||
from diet_guard._manual_workout_parse import logged_window, manual_session
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
RUNNERUP_DIRS: tuple[Path, ...] = (
|
||||
Path.home() / "data/cloud/RunnerUp",
|
||||
Path.home() / "data/cloud/RunnerUp/processed",
|
||||
)
|
||||
WINDOW_DAYS = 28
|
||||
_TCX_NS = {"t": "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"}
|
||||
# "Auto-scanned: Running: 5.6 km in 60 min" -- the sport sits between colons.
|
||||
_AUTO_SCAN_COLONS = 2
|
||||
_MINUTES_RE = re.compile(r"\((\d+(?:\.\d+)?) min")
|
||||
_SPORT_KINDS = {
|
||||
"running": "run",
|
||||
"run": "run",
|
||||
"walking": "walk",
|
||||
"walk": "walk",
|
||||
"hiking": "walk",
|
||||
"biking": "cycle",
|
||||
"cycling": "cycle",
|
||||
"stationary bike": "cycle",
|
||||
"strength": "strength",
|
||||
"gym": "strength",
|
||||
"weights": "strength",
|
||||
}
|
||||
|
||||
|
||||
def _kind(name: object) -> str:
|
||||
"""Map a sport/activity name onto a :class:`Session` kind."""
|
||||
return _SPORT_KINDS.get(str(name).strip().lower(), "other")
|
||||
|
||||
|
||||
def _tcx_session(path: Path) -> tuple[str, Session] | None:
|
||||
"""``(activity id, session)`` from one TCX file, or None if unusable."""
|
||||
try:
|
||||
root = parse_xml(path).getroot()
|
||||
except (ParseError, OSError) as exc:
|
||||
_logger.warning("Unreadable TCX %s (%s); skipped", path, exc)
|
||||
return None
|
||||
activity = root.find("t:Activities/t:Activity", _TCX_NS)
|
||||
ident = activity.findtext("t:Id", "", _TCX_NS) if activity is not None else ""
|
||||
if activity is None or not ident:
|
||||
return None
|
||||
seconds = metres = 0.0
|
||||
for lap in activity.findall("t:Lap", _TCX_NS):
|
||||
seconds += float(lap.findtext("t:TotalTimeSeconds", "0", _TCX_NS))
|
||||
metres += float(lap.findtext("t:DistanceMeters", "0", _TCX_NS))
|
||||
started = datetime.fromisoformat(ident).astimezone()
|
||||
sport = activity.get("Sport", "Other")
|
||||
session = Session(
|
||||
day=started.date().isoformat(),
|
||||
kind=_kind(sport),
|
||||
minutes=seconds / 60.0,
|
||||
km=metres / 1000.0 if metres > 0 else None,
|
||||
label=f"{sport} {metres / 1000:.1f} km",
|
||||
source="runnerup",
|
||||
start=started.isoformat(timespec="seconds"),
|
||||
end=(started + timedelta(seconds=seconds)).isoformat(timespec="seconds"),
|
||||
)
|
||||
return ident, session
|
||||
|
||||
|
||||
def runnerup_sessions(since: str) -> list[Session]:
|
||||
"""Every TCX session on or after ``since``, once per activity id."""
|
||||
found: dict[str, Session] = {}
|
||||
for folder in RUNNERUP_DIRS:
|
||||
for path in sorted(folder.glob("*.tcx")) if folder.is_dir() else []:
|
||||
parsed = _tcx_session(path)
|
||||
if parsed is not None and parsed[1].day >= since:
|
||||
found.setdefault(parsed[0], parsed[1])
|
||||
return sorted(found.values(), key=lambda s: s.day)
|
||||
|
||||
|
||||
def screen_locker_log_file() -> Path | None:
|
||||
"""screen-locker's ``log.json``, or None when the package is absent."""
|
||||
try:
|
||||
package = import_module("screen_locker")
|
||||
except ImportError:
|
||||
return None
|
||||
path = Path(str(package.__file__)).parent / "log.json"
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _minutes(data: dict[str, object]) -> float | None:
|
||||
"""A workout's duration from ``duration_minutes`` or its ``(N min`` text."""
|
||||
raw = data.get("duration_minutes")
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(str(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
# phone_verified entries carry no duration field, only "(86 min" prose.
|
||||
match = _MINUTES_RE.search(str(data.get("source", "")))
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _km(data: dict[str, object]) -> float | None:
|
||||
"""``distance_km`` when present and numeric."""
|
||||
try:
|
||||
return float(str(data["distance_km"]))
|
||||
except KeyError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _day_sessions(
|
||||
day: str, entries: list[dict[str, object]], tcx_days: set[str]
|
||||
) -> list[Session]:
|
||||
"""The sessions one screen-locker day contributes, after dedup."""
|
||||
pairs = [(e, e.get("workout_data")) for e in entries]
|
||||
items = [(e, d) for e, d in pairs if isinstance(d, dict)]
|
||||
types = {d.get("type") for _, d in items}
|
||||
out: list[Session] = []
|
||||
for entry, item in items:
|
||||
kind_type = item.get("type")
|
||||
minutes = _minutes(item)
|
||||
if minutes is None or minutes <= 0:
|
||||
continue
|
||||
label = str(item.get("source", kind_type))
|
||||
if kind_type == "pc_workout_verified" or (
|
||||
kind_type == "phone_verified" and "pc_workout_verified" not in types
|
||||
):
|
||||
start, end = logged_window(entry.get("timestamp"), minutes)
|
||||
out.append(
|
||||
Session(
|
||||
day, "strength", minutes, None, label, "screen-locker", start, end
|
||||
)
|
||||
)
|
||||
elif kind_type == "manual_workout":
|
||||
out.append(manual_session(day, item, minutes, label))
|
||||
elif kind_type == "runnerup_verified" and day not in tcx_days:
|
||||
sport = (
|
||||
label.split(":")[1]
|
||||
if label.count(":") >= _AUTO_SCAN_COLONS
|
||||
else "Running"
|
||||
)
|
||||
out.append(
|
||||
Session(day, _kind(sport), minutes, _km(item), label, "screen-locker")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def screen_locker_sessions(since: str, tcx_days: set[str]) -> list[Session]:
|
||||
"""Sessions from screen-locker's log on or after ``since``; [] if absent."""
|
||||
path = screen_locker_log_file()
|
||||
if path is None:
|
||||
return []
|
||||
try:
|
||||
log_io = import_module("screen_locker._log_io")
|
||||
log = log_io.load_workout_log(path)
|
||||
except (ImportError, AttributeError) as exc:
|
||||
_logger.warning("screen-locker log unreadable (%s); skipped", exc)
|
||||
return []
|
||||
sessions: list[Session] = []
|
||||
for day in sorted(log):
|
||||
if day >= since:
|
||||
sessions.extend(_day_sessions(day, log[day], tcx_days))
|
||||
return sessions
|
||||
|
||||
|
||||
def collect_sessions(today: date) -> tuple[Session, ...]:
|
||||
"""Every workout in the last :data:`WINDOW_DAYS`, deduplicated, by day."""
|
||||
since = (today - timedelta(days=WINDOW_DAYS)).isoformat()
|
||||
runs = runnerup_sessions(since)
|
||||
others = screen_locker_sessions(since, {s.day for s in runs})
|
||||
return tuple(sorted(runs + others, key=lambda s: (s.day, s.kind, s.minutes)))
|
||||
|
||||
|
||||
def sources_available() -> bool:
|
||||
"""Whether this machine has any workout source at all.
|
||||
|
||||
Guards publishing: a device with neither RunnerUp's folder nor
|
||||
screen-locker would otherwise publish an empty list over the real one.
|
||||
"""
|
||||
return any(folder.is_dir() for folder in RUNNERUP_DIRS) or (
|
||||
screen_locker_log_file() is not None
|
||||
)
|
||||
87
diet_guard/_body_activity_store.py
Normal file
87
diet_guard/_body_activity_store.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""The Body document's workout half: published sessions, on disk and back.
|
||||
|
||||
Split out of :mod:`diet_guard._body_store` for the 250-line cap. The PC
|
||||
writes these (:func:`set_activity`, from ``_sync_body.publish_activity``);
|
||||
every device reads them. Reads are tolerant like the rest of the document.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/services/body_service.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
|
||||
from diet_guard._activity_kcal import Session
|
||||
from diet_guard._body_store import as_number, now_stamp, read_body, write_body
|
||||
|
||||
__all__ = ["activity", "session_from_json", "session_to_json", "set_activity"]
|
||||
|
||||
|
||||
def session_to_json(session: Session) -> dict[str, object]:
|
||||
"""The wire/disk form of one session (short keys, see the docs)."""
|
||||
raw = asdict(session)
|
||||
return {
|
||||
"day": raw["day"],
|
||||
"kind": raw["kind"],
|
||||
"min": raw["minutes"],
|
||||
"km": raw["km"],
|
||||
"label": raw["label"],
|
||||
"src": raw["source"],
|
||||
"start": raw["start"],
|
||||
"end": raw["end"],
|
||||
}
|
||||
|
||||
|
||||
def _stamp_or_none(value: object) -> str | None:
|
||||
"""An ISO time string, or None when absent or unparsable."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def session_from_json(raw: object) -> Session | None:
|
||||
"""Parse one stored session, or None when malformed."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
day, kind, km_raw = raw.get("day"), raw.get("kind"), raw.get("km")
|
||||
minutes, km = as_number(raw.get("min")), as_number(km_raw)
|
||||
km_ok = km_raw is None or km is not None
|
||||
if not (isinstance(day, str) and isinstance(kind, str) and minutes and km_ok):
|
||||
return None
|
||||
return Session(
|
||||
day=day,
|
||||
kind=kind,
|
||||
minutes=minutes,
|
||||
km=km,
|
||||
label=str(raw.get("label", "")),
|
||||
source=str(raw.get("src", "")),
|
||||
start=_stamp_or_none(raw.get("start")),
|
||||
end=_stamp_or_none(raw.get("end")),
|
||||
)
|
||||
|
||||
|
||||
def activity() -> tuple[tuple[Session, ...], str | None]:
|
||||
"""The published sessions and their stamp; ``((), None)`` when none."""
|
||||
section = read_body()["activity"]
|
||||
stamp = section.get("t")
|
||||
raw = section.get("sessions")
|
||||
if not isinstance(raw, list) or not isinstance(stamp, str):
|
||||
return (), None
|
||||
parsed = (session_from_json(item) for item in raw)
|
||||
return tuple(s for s in parsed if s is not None), stamp
|
||||
|
||||
|
||||
def set_activity(sessions: tuple[Session, ...]) -> bool:
|
||||
"""Store ``sessions`` (stamped now) if they differ; True when written."""
|
||||
doc = read_body()
|
||||
payload = [session_to_json(s) for s in sessions]
|
||||
if doc["activity"].get("sessions") == payload:
|
||||
return False
|
||||
doc["activity"] = {"sessions": payload, "t": now_stamp()}
|
||||
write_body(doc)
|
||||
return True
|
||||
221
diet_guard/_body_calc.py
Normal file
221
diet_guard/_body_calc.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""BMI, ideal-weight and resting-metabolism formulas -- pure arithmetic.
|
||||
|
||||
Every formula the Body tab shows lives here (and, byte-for-byte, in
|
||||
``app/lib/services/body_calc.dart``). Nothing here reads a file or a clock:
|
||||
the caller supplies the person's :class:`~._budget_biometrics.Biometrics`.
|
||||
|
||||
Both halves are gated by one shared fixture, ``tests/fixtures/body_calc.json``
|
||||
(regenerate with ``scripts/build_body_fixture.py``). Rounding goes through
|
||||
:func:`half_up`, never ``round``: Python's is banker's and Dart's is
|
||||
half-away-from-zero, so a bare ``round`` would print different numbers on the
|
||||
two devices for the same person.
|
||||
|
||||
Katch-McArdle and Cunningham need a logged body-fat %; until one exists they
|
||||
come back as ``None`` so the screens can say what is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
from diet_guard._budget_biometrics import Biometrics, mifflin_st_jeor_bmr
|
||||
|
||||
__all__ = [
|
||||
"BMI_PRIME_NOTE",
|
||||
"BROCA_NOTE",
|
||||
"KATCH_MCARDLE",
|
||||
"TREFETHEN_NOTE",
|
||||
"BmiReport",
|
||||
"Formula",
|
||||
"bmi_report",
|
||||
"bmr_formulas",
|
||||
"half_up",
|
||||
"healthy_range",
|
||||
"ideal_summary",
|
||||
"ideal_weights",
|
||||
"table_bmr",
|
||||
]
|
||||
|
||||
_CM_PER_INCH = 2.54
|
||||
# The inch-based formulas (Devine, Robinson, Miller, Hamwi) are all
|
||||
# "base + k per inch over five feet"; below five feet they are undefined.
|
||||
_FIVE_FEET_IN = 60.0
|
||||
# Adult BMI categories are WHO's and do not apply to anyone still growing.
|
||||
ADULT_AGE = 20
|
||||
# BMI Prime divides by the top of the normal range.
|
||||
_BMI_PRIME_DIVISOR = 25.0
|
||||
_HEALTHY_BMI_LOW = 18.5
|
||||
_HEALTHY_BMI_HIGH = 24.9
|
||||
_TARGET_BMI = 22.0
|
||||
|
||||
# (upper bound exclusive, label), WHO adult classification.
|
||||
_BMI_BANDS: tuple[tuple[float, str], ...] = (
|
||||
(16.0, "severe thinness"),
|
||||
(17.0, "moderate thinness"),
|
||||
(18.5, "mild thinness"),
|
||||
(25.0, "normal"),
|
||||
(30.0, "overweight"),
|
||||
(35.0, "obese class I"),
|
||||
(40.0, "obese class II"),
|
||||
)
|
||||
_TOP_BAND = "obese class III"
|
||||
|
||||
# Plain-language readings, shown under the numbers on both surfaces.
|
||||
BMI_PRIME_NOTE = (
|
||||
"BMI Prime = BMI / 25: 1.00 is the top of the healthy range, "
|
||||
"so 1.02 means 2% above it."
|
||||
)
|
||||
TREFETHEN_NOTE = (
|
||||
"Trefethen's new BMI = 1.3 x kg / m^2.5: corrects classic BMI, which "
|
||||
"over-reads tall people and under-reads short ones."
|
||||
)
|
||||
KATCH_MCARDLE = "Katch-McArdle"
|
||||
BROCA_NOTE = (
|
||||
"Broca = height in cm - 100 (1871); it overshoots, so Broca (modified) "
|
||||
"takes 10% off it for men and 15% for women."
|
||||
)
|
||||
|
||||
|
||||
def half_up(value: float, digits: int = 0) -> float:
|
||||
"""Round ``value`` half-up at ``digits`` decimals, identically to Dart."""
|
||||
scale = 10.0**digits
|
||||
return math.floor(value * scale + 0.5) / scale
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Formula:
|
||||
"""One named formula's result; ``value`` is None when it does not apply."""
|
||||
|
||||
name: str
|
||||
value: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BmiReport:
|
||||
"""Body-mass index and its readings.
|
||||
|
||||
Attributes:
|
||||
bmi: Classic BMI, kg / m^2.
|
||||
prime: BMI divided by 25 (1.0 = top of the normal range).
|
||||
trefethen: Trefethen's "new BMI", 1.3 * kg / m^2.5.
|
||||
category: WHO adult band, or a note that it does not apply (< 20 y).
|
||||
"""
|
||||
|
||||
bmi: float
|
||||
prime: float
|
||||
trefethen: float
|
||||
category: str
|
||||
|
||||
|
||||
def _category(bmi: float, age_years: float) -> str:
|
||||
"""WHO adult band for ``bmi``; under-20s get a note instead."""
|
||||
if age_years < ADULT_AGE:
|
||||
return "adult categories do not apply under 20"
|
||||
for upper, label in _BMI_BANDS:
|
||||
if bmi < upper:
|
||||
return label
|
||||
return _TOP_BAND
|
||||
|
||||
|
||||
def bmi_report(bio: Biometrics) -> BmiReport:
|
||||
"""Return BMI, BMI Prime, Trefethen's BMI and the WHO category."""
|
||||
metres = bio.height_cm / 100.0
|
||||
bmi = bio.weight_kg / (metres * metres)
|
||||
return BmiReport(
|
||||
bmi=bmi,
|
||||
prime=bmi / _BMI_PRIME_DIVISOR,
|
||||
trefethen=1.3 * bio.weight_kg / metres**2.5,
|
||||
category=_category(bmi, bio.age_years),
|
||||
)
|
||||
|
||||
|
||||
def _per_inch(
|
||||
inches: float, base: float, per_inch: float, *, defined: bool
|
||||
) -> float | None:
|
||||
"""``base + per_inch * inches``, or None below five feet."""
|
||||
return base + per_inch * inches if defined else None
|
||||
|
||||
|
||||
def ideal_weights(bio: Biometrics) -> tuple[Formula, ...]:
|
||||
"""Every ideal-body-weight formula we can evaluate, in kg.
|
||||
|
||||
Only Creff uses age -- it is the one that answers "ideal weight for my age
|
||||
and height". The BMI healthy range is :func:`healthy_range`, shown with
|
||||
BMI rather than here; :func:`ideal_summary` averages these.
|
||||
"""
|
||||
h = bio.height_cm
|
||||
inches = h / _CM_PER_INCH - _FIVE_FEET_IN
|
||||
ok = inches >= 0
|
||||
male = bio.is_male
|
||||
metres = h / 100.0
|
||||
return (
|
||||
Formula("Devine", _per_inch(inches, 50.0 if male else 45.5, 2.3, defined=ok)),
|
||||
Formula(
|
||||
"Robinson",
|
||||
_per_inch(inches, 52.0 if male else 49.0, 1.9 if male else 1.7, defined=ok),
|
||||
),
|
||||
Formula(
|
||||
"Miller",
|
||||
_per_inch(
|
||||
inches, 56.2 if male else 53.1, 1.41 if male else 1.36, defined=ok
|
||||
),
|
||||
),
|
||||
Formula(
|
||||
"Hamwi",
|
||||
_per_inch(inches, 48.0 if male else 45.5, 2.7 if male else 2.2, defined=ok),
|
||||
),
|
||||
Formula("Broca", h - 100.0),
|
||||
Formula("Broca (modified)", (h - 100.0) * (0.9 if male else 0.85)),
|
||||
Formula("Lorentz", h - 100.0 - (h - 150.0) / (4.0 if male else 2.0)),
|
||||
Formula("Creff (age)", (h - 100.0 + bio.age_years / 10.0) * 0.9),
|
||||
Formula(
|
||||
"Peterson (BMI 22)", 2.2 * _TARGET_BMI + 3.5 * _TARGET_BMI * (metres - 1.5)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def healthy_range(bio: Biometrics) -> tuple[float, float]:
|
||||
"""The weights (kg) at BMI 18.5 and 24.9 for this height."""
|
||||
metres = bio.height_cm / 100.0
|
||||
return _HEALTHY_BMI_LOW * metres * metres, _HEALTHY_BMI_HIGH * metres * metres
|
||||
|
||||
|
||||
def ideal_summary(formulas: tuple[Formula, ...]) -> tuple[float, float, float] | None:
|
||||
"""``(average, lowest, highest)`` of the formulas that apply, or None."""
|
||||
values = [f.value for f in formulas if f.value is not None]
|
||||
if not values:
|
||||
return None
|
||||
return sum(values) / len(values), min(values), max(values)
|
||||
|
||||
|
||||
def bmr_formulas(
|
||||
bio: Biometrics, body_fat_pct: float | None = None
|
||||
) -> tuple[Formula, ...]:
|
||||
"""Resting metabolic rate by every formula, kcal/day.
|
||||
|
||||
Katch-McArdle and Cunningham work from lean mass, so they are None until
|
||||
a body-fat % is logged. Once one is, Katch-McArdle drives the calorie
|
||||
table (:func:`table_bmr`) -- it tracks muscle, which the weight-only
|
||||
formulas cannot.
|
||||
"""
|
||||
w, h, a = bio.weight_kg, bio.height_cm, bio.age_years
|
||||
if bio.is_male:
|
||||
revised = 88.362 + 13.397 * w + 4.799 * h - 5.677 * a
|
||||
else:
|
||||
revised = 447.593 + 9.247 * w + 3.098 * h - 4.330 * a
|
||||
lean = None if body_fat_pct is None else w * (1.0 - body_fat_pct / 100.0)
|
||||
return (
|
||||
Formula("Mifflin-St Jeor", mifflin_st_jeor_bmr(bio)),
|
||||
Formula("Harris-Benedict (revised 1984)", revised),
|
||||
Formula(KATCH_MCARDLE, None if lean is None else 370.0 + 21.6 * lean),
|
||||
Formula("Cunningham", None if lean is None else 500.0 + 22.0 * lean),
|
||||
)
|
||||
|
||||
|
||||
def table_bmr(bio: Biometrics, body_fat_pct: float | None) -> Formula:
|
||||
"""The BMR the calorie table uses: Katch-McArdle with body fat, else Mifflin."""
|
||||
formulas = {f.name: f for f in bmr_formulas(bio, body_fat_pct)}
|
||||
if body_fat_pct is not None:
|
||||
return formulas[KATCH_MCARDLE]
|
||||
return formulas["Mifflin-St Jeor"]
|
||||
164
diet_guard/_body_energy.py
Normal file
164
diet_guard/_body_energy.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""The daily calorie target for one chosen goal -- the Body tab's top line.
|
||||
|
||||
"To **lose** **0.5** **kg** / **week** on **Moderate** eat **2019** kcal":
|
||||
every bold word is a choice (:class:`Goal`), remembered and synced
|
||||
(``_body_prefs``). Informational only: nothing here writes the budget.
|
||||
|
||||
TDEE is the table BMR (Mifflin-St Jeor, or Katch-McArdle once a body-fat % is
|
||||
logged -- ``_body_calc.table_bmr``) times an activity factor, or for a
|
||||
"Measured" choice ``BMR x 1.2`` plus the real mean daily exercise from
|
||||
:func:`diet_guard._activity_kcal.daily_exercise` by that formula. The target
|
||||
is ``TDEE -/+ (amount in kg per day) x 7700``. A target below the 1200 kcal
|
||||
floor is reported and flagged, never clamped.
|
||||
|
||||
Mirrored in ``app/lib/services/body_energy.dart``; gated by the shared
|
||||
``tests/fixtures/body_calc.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from diet_guard._activity_kcal import FORMULAS
|
||||
from diet_guard._body_calc import half_up
|
||||
from diet_guard._budget_biometrics import _MIN_SANE_BUDGET
|
||||
|
||||
__all__ = [
|
||||
"ACTIVITY_CHOICES",
|
||||
"ACTIVITY_LEVELS",
|
||||
"DEFAULT_GOAL",
|
||||
"DIRECTIONS",
|
||||
"KCAL_PER_KG",
|
||||
"MEASURED_PREFIX",
|
||||
"MIN_TARGET_KCAL",
|
||||
"PRESET_AMOUNTS",
|
||||
"TIME_UNITS",
|
||||
"WEIGHT_UNITS",
|
||||
"ActivityLevel",
|
||||
"Goal",
|
||||
"Target",
|
||||
"activity_label",
|
||||
"goal_target",
|
||||
]
|
||||
|
||||
KCAL_PER_KG = 7700.0
|
||||
MIN_TARGET_KCAL = _MIN_SANE_BUDGET
|
||||
_SEDENTARY = 1.2
|
||||
# A "Measured" choice is ``measured:<formula>`` (MET, ACSM, Per-km).
|
||||
MEASURED_PREFIX = "measured:"
|
||||
|
||||
DIRECTIONS: tuple[str, ...] = ("lose", "maintain", "gain")
|
||||
# Offered in the amount picker; any other typed number is accepted too.
|
||||
PRESET_AMOUNTS: tuple[float, ...] = (0.25, 0.5, 0.75, 1.0)
|
||||
# Kilograms per unit.
|
||||
WEIGHT_UNITS: dict[str, float] = {
|
||||
"kg": 1.0,
|
||||
"lb": 0.45359237,
|
||||
"g": 0.001,
|
||||
"st": 6.35029318,
|
||||
}
|
||||
# Days per unit; a month is the mean Gregorian month.
|
||||
TIME_UNITS: dict[str, float] = {
|
||||
"day": 1.0,
|
||||
"week": 7.0,
|
||||
"month": 30.436875,
|
||||
"year": 365.2425,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivityLevel:
|
||||
"""One selectable activity level and its TDEE multiplier."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
factor: float
|
||||
meaning: str
|
||||
|
||||
|
||||
ACTIVITY_LEVELS: tuple[ActivityLevel, ...] = (
|
||||
ActivityLevel("sedentary", "Sedentary", 1.2, "little or no exercise"),
|
||||
ActivityLevel("light", "Light", 1.375, "exercise 1-3x a week"),
|
||||
ActivityLevel("moderate", "Moderate", 1.55, "exercise 3-5x a week"),
|
||||
ActivityLevel("very", "Very active", 1.725, "exercise 6-7x a week"),
|
||||
ActivityLevel("extra", "Extra active", 1.9, "training twice a day"),
|
||||
)
|
||||
# Every activity choice, in menu order: the five levels, then measured.
|
||||
ACTIVITY_CHOICES: tuple[str, ...] = (
|
||||
*(lvl.key for lvl in ACTIVITY_LEVELS),
|
||||
*(f"{MEASURED_PREFIX}{name}" for name in FORMULAS),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Goal:
|
||||
"""What the user wants: direction, amount per time, at an activity."""
|
||||
|
||||
direction: str
|
||||
amount: float
|
||||
weight_unit: str
|
||||
time_unit: str
|
||||
activity: str
|
||||
|
||||
def kg_per_day(self) -> float:
|
||||
"""Signed change in kg/day: positive loses, negative gains."""
|
||||
if self.direction == "maintain":
|
||||
return 0.0
|
||||
rate = self.amount * WEIGHT_UNITS[self.weight_unit] / TIME_UNITS[self.time_unit]
|
||||
return rate if self.direction == "lose" else -rate
|
||||
|
||||
|
||||
DEFAULT_GOAL = Goal("lose", 0.5, "kg", "week", "moderate")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""The answer to a :class:`Goal`, or why there is none.
|
||||
|
||||
Attributes:
|
||||
kcal: Daily target, or None when the goal needs measured activity
|
||||
that has not been published yet.
|
||||
tdee: The maintenance figure it was derived from, when known.
|
||||
bmr_name: The resting-burn formula the TDEE scales.
|
||||
"""
|
||||
|
||||
kcal: int | None
|
||||
tdee: float | None
|
||||
bmr_name: str
|
||||
|
||||
@property
|
||||
def below_floor(self) -> bool:
|
||||
"""Whether the target is under the 1200 kcal floor."""
|
||||
return self.kcal is not None and self.kcal < MIN_TARGET_KCAL
|
||||
|
||||
|
||||
def activity_label(key: str) -> str:
|
||||
"""Menu text for an activity choice, e.g. ``Measured (ACSM)``."""
|
||||
if key.startswith(MEASURED_PREFIX):
|
||||
return f"Measured ({key[len(MEASURED_PREFIX) :]})"
|
||||
for lvl in ACTIVITY_LEVELS:
|
||||
if lvl.key == key:
|
||||
return lvl.label
|
||||
return key
|
||||
|
||||
|
||||
def _tdee(bmr: float, activity: str, exercise: dict[str, float] | None) -> float | None:
|
||||
"""Maintenance kcal for ``activity``; None for measured without data."""
|
||||
if activity.startswith(MEASURED_PREFIX):
|
||||
formula = activity[len(MEASURED_PREFIX) :]
|
||||
if exercise is None or formula not in exercise:
|
||||
return None
|
||||
return bmr * _SEDENTARY + exercise[formula]
|
||||
factor = next((lvl.factor for lvl in ACTIVITY_LEVELS if lvl.key == activity), None)
|
||||
return None if factor is None else bmr * factor
|
||||
|
||||
|
||||
def goal_target(
|
||||
goal: Goal, bmr: float, bmr_name: str, exercise: dict[str, float] | None
|
||||
) -> Target:
|
||||
"""The daily kcal that meets ``goal`` from a ``bmr`` (kcal/day)."""
|
||||
tdee = _tdee(bmr, goal.activity, exercise)
|
||||
if tdee is None:
|
||||
return Target(kcal=None, tdee=None, bmr_name=bmr_name)
|
||||
kcal = int(half_up(tdee - goal.kg_per_day() * KCAL_PER_KG))
|
||||
return Target(kcal=kcal, tdee=tdee, bmr_name=bmr_name)
|
||||
50
diet_guard/_body_fat_store.py
Normal file
50
diet_guard/_body_fat_store.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""The Body document's body-fat log: one percentage per date, like weight.
|
||||
|
||||
Split out of :mod:`diet_guard._body_store` for the 250-line cap. The newest
|
||||
live entry switches the calorie table's base BMR to Katch-McArdle
|
||||
(``_body_calc.table_bmr``); older ones draw the body-fat graph.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/services/body_service.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from diet_guard._body_store import as_number, is_day, now_stamp, read_body, write_body
|
||||
|
||||
__all__ = [
|
||||
"MAX_BODY_FAT",
|
||||
"MIN_BODY_FAT",
|
||||
"body_fats",
|
||||
"newest_body_fat",
|
||||
"set_body_fat",
|
||||
]
|
||||
|
||||
# Outside this range (%) a reading is a typo, not a measurement.
|
||||
MIN_BODY_FAT = 3.0
|
||||
MAX_BODY_FAT = 70.0
|
||||
|
||||
|
||||
def body_fats() -> dict[str, float]:
|
||||
"""``YYYY-MM-DD`` -> body fat % for every live day, oldest first."""
|
||||
log = read_body()["bodyfat"]
|
||||
live = {
|
||||
day: as_number(cell.get("pct")) if isinstance(cell, dict) else None
|
||||
for day, cell in log.items()
|
||||
}
|
||||
return {
|
||||
day: pct for day, pct in sorted(live.items()) if pct is not None and is_day(day)
|
||||
}
|
||||
|
||||
|
||||
def newest_body_fat() -> float | None:
|
||||
"""The latest-dated live reading, or None when none is logged."""
|
||||
live = body_fats()
|
||||
return live[max(live)] if live else None
|
||||
|
||||
|
||||
def set_body_fat(day: str, pct: float | None) -> None:
|
||||
"""Record (or, with ``pct=None``, delete) ``day``'s body fat, stamped now."""
|
||||
doc = read_body()
|
||||
value = None if pct is None else round(float(pct), 1)
|
||||
doc["bodyfat"][day] = {"pct": value, "t": now_stamp()}
|
||||
write_body(doc)
|
||||
123
diet_guard/_body_ingest.py
Normal file
123
diet_guard/_body_ingest.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Phone weigh-ins (wake-alarm's morning session) -> the Body weight log.
|
||||
|
||||
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. 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
|
||||
are stamped midnight UTC of their date and only fill gaps, because that list
|
||||
is republished every morning with a fresh Hlc and would otherwise overwrite
|
||||
manual corrections daily.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/services/wake_alarm_weights.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc, MirrorSyncClient, RemoteSyncError
|
||||
|
||||
from diet_guard._body_store import MAX_KG, MIN_KG, WeightCandidate, stamp_ms
|
||||
|
||||
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]``.
|
||||
_PAIR_LEN = 2
|
||||
|
||||
|
||||
def _pair(log: object, field: str) -> tuple[object, str]:
|
||||
"""``(value, hlc)`` of one field on the ``alarm`` record, or ``(None, "")``."""
|
||||
record = log.get("alarm") if isinstance(log, dict) else None
|
||||
fields = record.get("fields") if isinstance(record, dict) else None
|
||||
pair = fields.get(field) if isinstance(fields, dict) else None
|
||||
if not isinstance(pair, list) or len(pair) < _PAIR_LEN:
|
||||
return None, ""
|
||||
return pair[0], str(pair[1])
|
||||
|
||||
|
||||
def _kg(value: object) -> float | None:
|
||||
"""A sane body weight, or None."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
kg = float(value)
|
||||
return kg if MIN_KG <= kg <= MAX_KG else None
|
||||
|
||||
|
||||
def _hlc_stamp(hlc: str) -> str | None:
|
||||
"""The ISO instant an Hlc string was minted at, or None if unparsable."""
|
||||
try:
|
||||
wall = Hlc.from_str(hlc).wall_time_ms
|
||||
except ValueError:
|
||||
return None
|
||||
return datetime.fromtimestamp(wall / 1000, tz=UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _from_log(log: object) -> list[WeightCandidate]:
|
||||
"""Every weigh-in one device's alarm log offers."""
|
||||
found: list[WeightCandidate] = []
|
||||
sessions, _ = _pair(log, _SESSIONS_FIELD)
|
||||
for entry in sessions if isinstance(sessions, list) else []:
|
||||
day = entry.get("date") if isinstance(entry, dict) else None
|
||||
kg = _kg(entry.get("weight_kg")) if isinstance(entry, dict) else None
|
||||
if isinstance(day, str) and kg is not None:
|
||||
found.append(WeightCandidate(day, kg, "phone", f"{day}T00:00:00+00:00"))
|
||||
latest, hlc = _pair(log, _LATEST_FIELD)
|
||||
day = latest.get("date") if isinstance(latest, dict) else None
|
||||
kg = _kg(latest.get("kg")) if isinstance(latest, dict) else None
|
||||
stamp = _hlc_stamp(hlc)
|
||||
if isinstance(day, str) and kg is not None and stamp is not None:
|
||||
found.append(WeightCandidate(day, kg, "phone", stamp))
|
||||
return found
|
||||
|
||||
|
||||
def weight_candidates(published: list[tuple[str, str]]) -> list[WeightCandidate]:
|
||||
"""One candidate per date -- the newest-stamped -- across every device.
|
||||
|
||||
Args:
|
||||
published: ``(device_id, raw alarm.json)`` per wake-alarm device.
|
||||
"""
|
||||
best: dict[str, WeightCandidate] = {}
|
||||
for _, raw in published:
|
||||
try:
|
||||
log = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for cand in _from_log(log):
|
||||
held = best.get(cand.day)
|
||||
if held is None or (stamp_ms(cand.t), cand.kg) > (
|
||||
stamp_ms(held.t),
|
||||
held.kg,
|
||||
):
|
||||
best[cand.day] = cand
|
||||
return [best[day] for day in sorted(best)]
|
||||
|
||||
|
||||
def fetch_alarm_logs(client: RemoteStore) -> list[tuple[str, str]]:
|
||||
"""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.
|
||||
"""
|
||||
store = client.primary if isinstance(client, MirrorSyncClient) else client
|
||||
try:
|
||||
published = []
|
||||
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 RemoteSyncError as exc:
|
||||
_logger.info("wake-alarm weigh-ins unreadable (%s); skipped", exc)
|
||||
return []
|
||||
return published
|
||||
69
diet_guard/_body_prefs.py
Normal file
69
diet_guard/_body_prefs.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""The calorie goal the user last picked -- remembered and synced.
|
||||
|
||||
Stored as one extra field, ``goal``, on the synced ``profile`` record, so the
|
||||
sentence "To lose 0.5 kg / week on Moderate eat ..." reads the same on the PC
|
||||
gate and the phone. Each part is validated on read: an unknown direction,
|
||||
unit or activity (a peer on a newer version, a typo) falls back to that part
|
||||
of :data:`~diet_guard._body_energy.DEFAULT_GOAL` rather than breaking the tab.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/services/body_prefs.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from diet_guard._body_energy import (
|
||||
ACTIVITY_CHOICES,
|
||||
DEFAULT_GOAL,
|
||||
DIRECTIONS,
|
||||
TIME_UNITS,
|
||||
WEIGHT_UNITS,
|
||||
Goal,
|
||||
)
|
||||
from diet_guard._body_store import as_number, read_body, set_profile
|
||||
|
||||
__all__ = ["GOAL_FIELD", "goal_from_json", "goal_to_json", "saved_goal", "set_goal"]
|
||||
|
||||
GOAL_FIELD = "goal"
|
||||
|
||||
|
||||
def goal_to_json(goal: Goal) -> dict[str, object]:
|
||||
"""The wire/disk form (short keys)."""
|
||||
return {
|
||||
"dir": goal.direction,
|
||||
"amt": goal.amount,
|
||||
"wu": goal.weight_unit,
|
||||
"tu": goal.time_unit,
|
||||
"act": goal.activity,
|
||||
}
|
||||
|
||||
|
||||
def goal_from_json(raw: object) -> Goal:
|
||||
"""Parse a stored goal part by part, defaulting whatever is unusable."""
|
||||
if not isinstance(raw, dict):
|
||||
return DEFAULT_GOAL
|
||||
amount = as_number(raw.get("amt"))
|
||||
goal = DEFAULT_GOAL
|
||||
if raw.get("dir") in DIRECTIONS:
|
||||
goal = replace(goal, direction=str(raw["dir"]))
|
||||
if amount is not None and amount >= 0:
|
||||
goal = replace(goal, amount=amount)
|
||||
if raw.get("wu") in WEIGHT_UNITS:
|
||||
goal = replace(goal, weight_unit=str(raw["wu"]))
|
||||
if raw.get("tu") in TIME_UNITS:
|
||||
goal = replace(goal, time_unit=str(raw["tu"]))
|
||||
if raw.get("act") in ACTIVITY_CHOICES:
|
||||
goal = replace(goal, activity=str(raw["act"]))
|
||||
return goal
|
||||
|
||||
|
||||
def saved_goal() -> Goal:
|
||||
"""The remembered goal (the default until one is saved)."""
|
||||
cell = read_body()["profile"].get(GOAL_FIELD)
|
||||
return goal_from_json(cell.get("v") if isinstance(cell, dict) else None)
|
||||
|
||||
|
||||
def set_goal(goal: Goal) -> None:
|
||||
"""Remember (and sync) ``goal``."""
|
||||
set_profile(**{GOAL_FIELD: goal_to_json(goal)})
|
||||
206
diet_guard/_body_report.py
Normal file
206
diet_guard/_body_report.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Render a :class:`~diet_guard._body_view.BodyView` as aligned text blocks.
|
||||
|
||||
One renderer for both surfaces: the CLI prints the blocks, the gate's Body
|
||||
tab shows each in a monospace panel. Numbers go through ``half_up`` so they
|
||||
match the phone to the digit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import textwrap
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._activity_kcal import (
|
||||
FORMULAS,
|
||||
METHODS_NOTE,
|
||||
session_kcal,
|
||||
)
|
||||
from diet_guard._body_calc import BMI_PRIME_NOTE, BROCA_NOTE, TREFETHEN_NOTE, half_up
|
||||
from diet_guard._body_energy import KCAL_PER_KG, MIN_TARGET_KCAL, activity_label
|
||||
from diet_guard._body_steps import STEP_BASELINE
|
||||
from diet_guard._body_store import age_on
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._body_energy import Goal
|
||||
from diet_guard._body_view import BodyView
|
||||
from diet_guard._week_plan import WeekPlan
|
||||
|
||||
_NAME_W = 24
|
||||
_COL_W = 15
|
||||
_SESSIONS_SHOWN = 10
|
||||
_WRAP = 88
|
||||
_NEEDS = "Set birth date, height, sex and a weight to see the numbers."
|
||||
|
||||
|
||||
def _wrap(text: str, indent: str = "") -> list[str]:
|
||||
"""Explanatory prose broken at :data:`_WRAP` columns.
|
||||
|
||||
One unwrapped note once widened the gate's left panel enough to push the
|
||||
whole right column off-screen.
|
||||
"""
|
||||
return textwrap.wrap(text, _WRAP, initial_indent=indent, subsequent_indent=indent)
|
||||
|
||||
|
||||
def _num(value: float, digits: int = 0) -> str:
|
||||
"""``value`` rounded half-up to ``digits`` decimals."""
|
||||
return f"{half_up(value, digits):.{digits}f}"
|
||||
|
||||
|
||||
def profile_lines(view: BodyView, today: date) -> list[str]:
|
||||
"""Who the numbers are for, and what is still missing."""
|
||||
prof = view.profile
|
||||
birth = "birth date not set"
|
||||
if prof.birth is not None:
|
||||
age = age_on(date.fromisoformat(prof.birth), today)
|
||||
birth = f"born {prof.birth} ({age} y)"
|
||||
height = f"{_num(prof.height_cm)} cm" if prof.height_cm else "height not set"
|
||||
sex = {"m": "male", "f": "female"}.get(prof.sex or "", "sex not set")
|
||||
newest = max(view.weights) if view.weights else None
|
||||
weight = (
|
||||
f"{_num(view.weights[newest], 1)} kg on {newest} "
|
||||
f"({len(view.weights)} weigh-ins)"
|
||||
if newest
|
||||
else "no weight logged yet"
|
||||
)
|
||||
return [f"Profile: {birth}, {height}, {sex}", f"Weight: {weight}"]
|
||||
|
||||
|
||||
def bodyfat_lines(view: BodyView) -> list[str]:
|
||||
"""The newest body-fat reading and what it switches on."""
|
||||
if not view.body_fats:
|
||||
return ["Body fat: not logged (log one to use Katch-McArdle)."]
|
||||
day = max(view.body_fats)
|
||||
return [
|
||||
f"Body fat: {_num(view.body_fats[day], 1)}% on {day} "
|
||||
f"({len(view.body_fats)} readings) -- targets use Katch-McArdle."
|
||||
]
|
||||
|
||||
|
||||
def bmi_lines(view: BodyView) -> list[str]:
|
||||
"""BMI with plain-language readings, and the healthy weight range."""
|
||||
if view.numbers is None:
|
||||
return [_NEEDS]
|
||||
bmi = view.numbers.bmi
|
||||
low, high = view.numbers.healthy
|
||||
return [
|
||||
f"BMI {_num(bmi.bmi, 1)} ({bmi.category})",
|
||||
f"Healthy weight for your height (BMI 18.5-24.9): "
|
||||
f"{_num(low, 1)}-{_num(high, 1)} kg",
|
||||
f"BMI Prime {_num(bmi.prime, 2)} Trefethen {_num(bmi.trefethen, 1)}",
|
||||
*_wrap(BMI_PRIME_NOTE, " "),
|
||||
*_wrap(TREFETHEN_NOTE, " "),
|
||||
]
|
||||
|
||||
|
||||
def ideal_lines(view: BodyView) -> list[str]:
|
||||
"""Every ideal-weight formula, then their average and range."""
|
||||
if view.numbers is None:
|
||||
return [_NEEDS]
|
||||
lines = ["Ideal weight:"]
|
||||
for formula in view.numbers.ideal:
|
||||
value = (
|
||||
"n/a (< 152 cm)"
|
||||
if formula.value is None
|
||||
else f"{_num(formula.value, 1)} kg"
|
||||
)
|
||||
lines.append(f" {formula.name:<{_NAME_W}}{value}")
|
||||
summary = view.numbers.ideal_summary
|
||||
if summary is not None:
|
||||
avg, low, high = summary
|
||||
lines.append(f" {'Average of all':<{_NAME_W}}{_num(avg, 1)} kg")
|
||||
lines.append(f" {'Ideal range':<{_NAME_W}}{_num(low, 1)}-{_num(high, 1)} kg")
|
||||
lines.extend(_wrap(BROCA_NOTE, " "))
|
||||
return lines
|
||||
|
||||
|
||||
def bmr_lines(view: BodyView) -> list[str]:
|
||||
"""Resting metabolism by formula; body-fat ones wait for a reading."""
|
||||
if view.numbers is None:
|
||||
return [_NEEDS]
|
||||
lines = ["Resting metabolism (kcal/day):"]
|
||||
for f in view.numbers.bmr:
|
||||
value = "n/a -- log a body-fat %" if f.value is None else _num(f.value)
|
||||
lines.append(f" {f.name:<{_NAME_W + 8}}{value}")
|
||||
return lines
|
||||
|
||||
|
||||
def goal_sentence(goal: Goal) -> str:
|
||||
"""``To lose 0.5 kg / week on Moderate`` -- the goal as words."""
|
||||
activity = activity_label(goal.activity)
|
||||
if goal.direction == "maintain":
|
||||
return f"To maintain on {activity}"
|
||||
amount = f"{goal.amount:g} {goal.weight_unit} / {goal.time_unit}"
|
||||
return f"To {goal.direction} {amount} on {activity}"
|
||||
|
||||
|
||||
def target_lines(view: BodyView) -> list[str]:
|
||||
"""The top line: what to eat for the chosen goal, and how it was got."""
|
||||
if view.numbers is None:
|
||||
return [_NEEDS]
|
||||
target = view.numbers.target
|
||||
if target.kcal is None or target.tdee is None:
|
||||
return [
|
||||
f"{goal_sentence(view.goal)}: no workouts published yet "
|
||||
"(the PC publishes them on sync)."
|
||||
]
|
||||
lines = [f"{goal_sentence(view.goal)} eat {target.kcal} kcal a day."]
|
||||
if target.below_floor:
|
||||
lines.append(f" That is below the {MIN_TARGET_KCAL} kcal floor.")
|
||||
lines.append(
|
||||
f" Maintenance {_num(target.tdee)} kcal ({target.bmr_name} resting burn "
|
||||
f"x activity); 1 kg of fat = {KCAL_PER_KG:g} kcal."
|
||||
)
|
||||
lines.extend(_wrap("Informational only -- the budget is never changed here.", " "))
|
||||
return lines
|
||||
|
||||
|
||||
def activity_lines(view: BodyView) -> list[str]:
|
||||
"""Measured exercise averages and the newest published workouts."""
|
||||
if view.activity_stamp is None and not view.steps:
|
||||
return ["No workouts published yet (the PC publishes them on sync)."]
|
||||
stamp = (view.activity_stamp or "")[:16].replace("T", " ") or "not yet"
|
||||
lines = [f"Workouts published {stamp}"]
|
||||
exercise = view.exercise
|
||||
if exercise is not None:
|
||||
means = " ".join(
|
||||
f"{name} {_num(kcal)}" for name, kcal in exercise.means.items()
|
||||
)
|
||||
lines.append(f"Last {exercise.days} full days, net exercise kcal/day: {means}")
|
||||
if exercise is not None and view.steps:
|
||||
newest = max(view.steps)
|
||||
lines.append(
|
||||
f"Steps outside workouts: {view.steps[newest]} on {newest}; above "
|
||||
f"{STEP_BASELINE}/day they add {_num(exercise.steps_kcal)} kcal/day "
|
||||
"(included above)."
|
||||
)
|
||||
weight = view.weight
|
||||
for session in view.sessions[-_SESSIONS_SHOWN:][::-1]:
|
||||
km = f" {_num(session.km, 1)} km" if session.km else ""
|
||||
kcal = ""
|
||||
if weight is not None:
|
||||
result = session_kcal(session, weight)
|
||||
kcal = " " + " ".join(
|
||||
f"{name[0]}{_num(result.kcal[name])}" for name in FORMULAS
|
||||
)
|
||||
lines.append(
|
||||
f" {session.day} {session.kind:<8}{_num(session.minutes):>4} min{km}{kcal}"
|
||||
)
|
||||
lines.extend(_wrap(f"(M = MET, A = ACSM, P = Per-km) {METHODS_NOTE}"))
|
||||
return lines
|
||||
|
||||
|
||||
def plan_lines(plan: WeekPlan) -> list[str]:
|
||||
"""The week plan, one line per day."""
|
||||
lines = [f"Week plan for an average of {plan.target_avg} kcal/day:"]
|
||||
for day in plan.days:
|
||||
note = {"unlogged": " (no log - type it?)"}.get(day.source, "")
|
||||
lines.append(f" {day.weekday} {day.day} {day.kcal:>5} {day.source}{note}")
|
||||
if plan.per_remaining is None:
|
||||
lines.append(
|
||||
f"Every day is known: the week averages {_num(plan.week_avg)} kcal."
|
||||
)
|
||||
else:
|
||||
warn = f" -- below the {MIN_TARGET_KCAL} floor!" if plan.below_floor else ""
|
||||
lines.append(f"Eat {plan.per_remaining} kcal on each remaining day{warn}")
|
||||
return lines
|
||||
59
diet_guard/_body_steps.py
Normal file
59
diet_guard/_body_steps.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Daily steps from the phone's Health Connect, turned into exercise kcal.
|
||||
|
||||
The **phone** writes these (it holds the Health Connect permission): one
|
||||
count per day of steps taken *outside* every published workout's
|
||||
``start..end`` window, so a recorded walk is never counted as both a workout
|
||||
and steps. Every device reads them.
|
||||
|
||||
Only steps above :data:`STEP_BASELINE` count: the ``BMR x 1.2`` sedentary
|
||||
baseline the measured TDEE starts from already assumes a few thousand steps
|
||||
of ordinary daily movement. The rest are walking at the per-km rate
|
||||
(``_activity_kcal``: 0.5 kcal per kg per km, net of resting), over a stride
|
||||
estimated from height.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/services/body_steps.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._body_store import as_number, is_day, read_body
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._budget_biometrics import Biometrics
|
||||
|
||||
__all__ = ["STEP_BASELINE", "daily_steps", "step_kcal", "stride_m"]
|
||||
|
||||
STEP_BASELINE = 4000
|
||||
# Stride as a fraction of height (the common pedometer estimate).
|
||||
_STRIDE_MALE = 0.415
|
||||
_STRIDE_FEMALE = 0.413
|
||||
_WALK_KCAL_PER_KG_KM = 0.5
|
||||
_M_PER_KM = 1000.0
|
||||
|
||||
|
||||
def daily_steps() -> dict[str, int]:
|
||||
"""``YYYY-MM-DD`` -> steps outside workouts, oldest first."""
|
||||
log = read_body()["steps"]
|
||||
counts = {
|
||||
day: as_number(cell.get("n")) if isinstance(cell, dict) else None
|
||||
for day, cell in log.items()
|
||||
}
|
||||
return {
|
||||
day: int(n)
|
||||
for day, n in sorted(counts.items())
|
||||
if n is not None and is_day(day)
|
||||
}
|
||||
|
||||
|
||||
def stride_m(bio: Biometrics) -> float:
|
||||
"""Estimated walking stride in metres."""
|
||||
return bio.height_cm / 100.0 * (_STRIDE_MALE if bio.is_male else _STRIDE_FEMALE)
|
||||
|
||||
|
||||
def step_kcal(steps: int, bio: Biometrics) -> float:
|
||||
"""Net kcal of the steps above the baseline, as walking."""
|
||||
extra = max(0, steps - STEP_BASELINE)
|
||||
km = extra * stride_m(bio) / _M_PER_KM
|
||||
return km * _WALK_KCAL_PER_KG_KM * bio.weight_kg
|
||||
210
diet_guard/_body_store.py
Normal file
210
diet_guard/_body_store.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""The local Body document: profile, per-day weight log, published workouts.
|
||||
|
||||
Shape and merge rules are in ``docs/DOCS-body.md``; the sync adapters are
|
||||
:mod:`diet_guard.sync_merge._body`. Every write stamps an edit time ``t`` so
|
||||
the per-field last-writer-wins merge has something to compare, and every read
|
||||
is tolerant: a malformed field reads as absent rather than raising, because a
|
||||
peer on a future version must not be able to break this device's Body tab.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/services/body_service.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from diet_guard._budget_biometrics import Biometrics
|
||||
from diet_guard._constants import BODY_FILE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_FILE_VERSION = 1
|
||||
# Every top-level section of the document (``bodyfat`` is a dated log like
|
||||
# ``weights``; see ``_body_fat_store``).
|
||||
SECTIONS: tuple[str, ...] = ("profile", "weights", "bodyfat", "activity", "steps")
|
||||
PROFILE_FIELDS: tuple[str, ...] = ("birth", "height_cm", "sex")
|
||||
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
||||
# A body weight outside this range is a typo or a unit mix-up, never real.
|
||||
MIN_KG = 20.0
|
||||
MAX_KG = 400.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
"""The person's stored body profile; any field may still be unset."""
|
||||
|
||||
birth: str | None
|
||||
height_cm: float | None
|
||||
sex: str | None
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Whether every field is set -- then the profile moves to the bottom."""
|
||||
return None not in (self.birth, self.height_cm, self.sex)
|
||||
|
||||
def biometrics(self, weight_kg: float, today: date) -> Biometrics | None:
|
||||
"""Biometrics for today, or None while the profile is incomplete."""
|
||||
if self.birth is None or self.height_cm is None or self.sex is None:
|
||||
return None
|
||||
return Biometrics(
|
||||
weight_kg=weight_kg,
|
||||
height_cm=self.height_cm,
|
||||
age_years=age_on(date.fromisoformat(self.birth), today),
|
||||
is_male=self.sex == "m",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeightCandidate:
|
||||
"""A weigh-in offered for the log, applied only if it is newer."""
|
||||
|
||||
day: str
|
||||
kg: float
|
||||
src: str
|
||||
t: str
|
||||
|
||||
|
||||
def age_on(birth: date, today: date) -> int:
|
||||
"""Completed years between ``birth`` and ``today``."""
|
||||
before_birthday = (today.month, today.day) < (birth.month, birth.day)
|
||||
return today.year - birth.year - int(before_birthday)
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
"""The current local time as the ISO edit stamp every write carries."""
|
||||
return datetime.now(tz=UTC).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def stamp_ms(stamp: object) -> int:
|
||||
"""Epoch milliseconds of an edit stamp; unparsable -> 0 (the epoch)."""
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(stamp))
|
||||
except ValueError:
|
||||
return 0
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=UTC)
|
||||
return int((moment - _EPOCH).total_seconds() * 1000)
|
||||
|
||||
|
||||
def read_body() -> dict[str, dict[str, object]]:
|
||||
"""The raw document, normalised to its three sections; never raises."""
|
||||
try:
|
||||
raw = json.loads(BODY_FILE.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
raw = {}
|
||||
except (OSError, ValueError) as exc:
|
||||
_logger.warning(
|
||||
"Unreadable body file %s (%s); treating as empty", BODY_FILE, exc
|
||||
)
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
return {
|
||||
section: value if isinstance(value := raw.get(section), dict) else {}
|
||||
for section in SECTIONS
|
||||
}
|
||||
|
||||
|
||||
def write_body(doc: dict[str, dict[str, object]]) -> None:
|
||||
"""Persist ``doc`` atomically enough for a single-writer local file."""
|
||||
BODY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"v": _FILE_VERSION, **doc}
|
||||
tmp = BODY_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=1, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(BODY_FILE)
|
||||
|
||||
|
||||
def _field(doc: dict[str, dict[str, object]], name: str) -> object:
|
||||
"""A profile field's value, or None when missing/malformed."""
|
||||
cell = doc["profile"].get(name)
|
||||
return cell.get("v") if isinstance(cell, dict) else None
|
||||
|
||||
|
||||
def as_number(value: object) -> float | None:
|
||||
"""``value`` as a float when it is a real (non-bool) number, else None."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def profile() -> Profile:
|
||||
"""The stored profile, with malformed fields read as unset."""
|
||||
doc = read_body()
|
||||
birth, height, sex = (_field(doc, name) for name in PROFILE_FIELDS)
|
||||
try:
|
||||
date.fromisoformat(str(birth))
|
||||
except ValueError:
|
||||
birth = None
|
||||
height_cm = as_number(height)
|
||||
return Profile(
|
||||
birth=birth if isinstance(birth, str) else None,
|
||||
height_cm=height_cm if height_cm is not None and height_cm > 0 else None,
|
||||
sex=sex if sex in {"m", "f"} else None,
|
||||
)
|
||||
|
||||
|
||||
def set_profile(**fields: object) -> None:
|
||||
"""Set any of ``birth``/``height_cm``/``sex``, each stamped now."""
|
||||
doc = read_body()
|
||||
stamp = now_stamp()
|
||||
for name, value in fields.items():
|
||||
doc["profile"][name] = {"v": value, "t": stamp}
|
||||
write_body(doc)
|
||||
|
||||
|
||||
def _kg(cell: object) -> float | None:
|
||||
"""A weight cell's kg, or None for a tombstone/malformed cell."""
|
||||
return as_number(cell.get("kg")) if isinstance(cell, dict) else None
|
||||
|
||||
|
||||
def is_day(key: str) -> bool:
|
||||
"""Whether ``key`` is a ``YYYY-MM-DD`` date (a peer could send anything)."""
|
||||
try:
|
||||
date.fromisoformat(key)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def weights() -> dict[str, float]:
|
||||
"""``YYYY-MM-DD`` -> kg for every live (non-deleted) day, oldest first."""
|
||||
live = {day: _kg(cell) for day, cell in read_body()["weights"].items()}
|
||||
return {
|
||||
day: kg for day, kg in sorted(live.items()) if kg is not None and is_day(day)
|
||||
}
|
||||
|
||||
|
||||
def newest_weight() -> tuple[str, float] | None:
|
||||
"""The latest-dated live weigh-in, or None when the log is empty."""
|
||||
live = weights()
|
||||
if not live:
|
||||
return None
|
||||
day = max(live)
|
||||
return day, live[day]
|
||||
|
||||
|
||||
def set_weight(day: str, kg: float | None, src: str = "manual") -> None:
|
||||
"""Record (or, with ``kg=None``, delete) ``day``'s weight, stamped now."""
|
||||
doc = read_body()
|
||||
value = None if kg is None else round(float(kg), 2)
|
||||
doc["weights"][day] = {"kg": value, "src": src, "t": now_stamp()}
|
||||
write_body(doc)
|
||||
|
||||
|
||||
def apply_weight_candidates(candidates: list[WeightCandidate]) -> int:
|
||||
"""Apply each candidate whose day is missing or older; return the count."""
|
||||
doc = read_body()
|
||||
applied = 0
|
||||
for cand in candidates:
|
||||
current = doc["weights"].get(cand.day)
|
||||
current_t = current.get("t") if isinstance(current, dict) else None
|
||||
if current is not None and stamp_ms(current_t) >= stamp_ms(cand.t):
|
||||
continue
|
||||
doc["weights"][cand.day] = {"kg": cand.kg, "src": cand.src, "t": cand.t}
|
||||
applied += 1
|
||||
if applied:
|
||||
write_body(doc)
|
||||
return applied
|
||||
186
diet_guard/_body_view.py
Normal file
186
diet_guard/_body_view.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""Everything the Body surfaces show, gathered once from local state.
|
||||
|
||||
The CLI's ``body`` command and the gate's Body tab both render a
|
||||
:class:`BodyView`; neither reads the store or runs a formula itself, so the
|
||||
two cannot drift. Local reads only -- no network -- because the gate builds
|
||||
this on the Tk thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._activity_kcal import daily_exercise, window_days
|
||||
from diet_guard._body_activity_store import activity
|
||||
from diet_guard._body_calc import (
|
||||
bmi_report,
|
||||
bmr_formulas,
|
||||
healthy_range,
|
||||
ideal_summary,
|
||||
ideal_weights,
|
||||
table_bmr,
|
||||
)
|
||||
from diet_guard._body_energy import goal_target
|
||||
from diet_guard._body_fat_store import body_fats
|
||||
from diet_guard._body_prefs import saved_goal
|
||||
from diet_guard._body_steps import daily_steps, step_kcal
|
||||
from diet_guard._body_store import profile, weights
|
||||
from diet_guard._budget import (
|
||||
BudgetFileCorruptError,
|
||||
BudgetNotInitializedError,
|
||||
daily_budget,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from diet_guard._activity_kcal import Session
|
||||
from diet_guard._body_calc import BmiReport, Formula
|
||||
from diet_guard._body_energy import Goal, Target
|
||||
from diet_guard._body_store import Profile
|
||||
from diet_guard._budget_biometrics import Biometrics
|
||||
|
||||
|
||||
# The app-wide unset-budget default (the gate's History tab and the phone use
|
||||
# the same 2200), so the week planner has a target from a fresh install.
|
||||
DEFAULT_TARGET_KCAL = 2200
|
||||
|
||||
|
||||
def budget_or_default() -> int:
|
||||
"""Today's budget, or :data:`DEFAULT_TARGET_KCAL` when unset or corrupt."""
|
||||
try:
|
||||
return daily_budget()
|
||||
except BudgetNotInitializedError, BudgetFileCorruptError:
|
||||
return DEFAULT_TARGET_KCAL
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BodyNumbers:
|
||||
"""The formula outputs, present only when profile and weight both exist."""
|
||||
|
||||
bio: Biometrics
|
||||
bmi: BmiReport
|
||||
ideal: tuple[Formula, ...]
|
||||
ideal_summary: tuple[float, float, float] | None
|
||||
healthy: tuple[float, float]
|
||||
bmr: tuple[Formula, ...]
|
||||
target: Target
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExerciseSummary:
|
||||
"""Measured daily exercise over the averaging window.
|
||||
|
||||
Attributes:
|
||||
means: Mean daily net kcal per formula, steps included.
|
||||
steps_kcal: The counted steps' share of each mean (same window).
|
||||
days: Full past days averaged over (1-14; see ``window_days``).
|
||||
"""
|
||||
|
||||
means: dict[str, float]
|
||||
steps_kcal: float
|
||||
days: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BodyView:
|
||||
"""The Body tab's whole state.
|
||||
|
||||
Attributes:
|
||||
profile: Stored profile (fields may be unset).
|
||||
goal: The calorie goal the target line answers.
|
||||
weights: Live weight log, oldest first.
|
||||
body_fats: Live body-fat log (%), oldest first.
|
||||
steps: Phone-published steps outside workouts, per day.
|
||||
sessions: Published workouts (empty when none).
|
||||
activity_stamp: When the workouts were published, or None.
|
||||
exercise: Measured exercise, or None without published activity or
|
||||
steps, or without a profile to scale them by.
|
||||
numbers: The formulas, or None while profile/weight are missing.
|
||||
"""
|
||||
|
||||
profile: Profile
|
||||
goal: Goal
|
||||
weights: dict[str, float]
|
||||
body_fats: dict[str, float]
|
||||
steps: dict[str, int]
|
||||
sessions: tuple[Session, ...]
|
||||
activity_stamp: str | None
|
||||
exercise: ExerciseSummary | None
|
||||
numbers: BodyNumbers | None
|
||||
|
||||
@property
|
||||
def weight(self) -> float | None:
|
||||
"""The newest logged weight, or None."""
|
||||
return self.weights[max(self.weights)] if self.weights else None
|
||||
|
||||
|
||||
def _numbers(
|
||||
bio: Biometrics, fat: float | None, goal: Goal, exercise: ExerciseSummary | None
|
||||
) -> BodyNumbers:
|
||||
"""Every formula for one person."""
|
||||
bmr = table_bmr(bio, fat)
|
||||
ideal = ideal_weights(bio)
|
||||
means = None if exercise is None else exercise.means
|
||||
return BodyNumbers(
|
||||
bio=bio,
|
||||
bmi=bmi_report(bio),
|
||||
ideal=ideal,
|
||||
ideal_summary=ideal_summary(ideal),
|
||||
healthy=healthy_range(bio),
|
||||
bmr=bmr_formulas(bio, fat),
|
||||
target=goal_target(goal, bmr.value or 0.0, bmr.name, means),
|
||||
)
|
||||
|
||||
|
||||
def _exercise(
|
||||
bio: Biometrics, sessions: tuple[Session, ...], steps: dict[str, int], today: date
|
||||
) -> ExerciseSummary:
|
||||
"""Averages over one shared window, so the steps share adds up."""
|
||||
by_day = {day: step_kcal(n, bio) for day, n in steps.items()}
|
||||
days = window_days(sessions, today, by_day)
|
||||
first = (today - timedelta(days=days)).isoformat()
|
||||
in_window = sum(k for d, k in by_day.items() if first <= d < today.isoformat())
|
||||
return ExerciseSummary(
|
||||
means=daily_exercise(sessions, bio.weight_kg, today, by_day),
|
||||
steps_kcal=in_window / days if days else 0.0,
|
||||
days=days,
|
||||
)
|
||||
|
||||
|
||||
def build_view(today: date, goal: Goal | None = None) -> BodyView:
|
||||
"""Read the store and evaluate every formula for ``today``.
|
||||
|
||||
Args:
|
||||
today: The local date (ages and the exercise window hang off it).
|
||||
goal: The calorie goal; None reads the saved one.
|
||||
"""
|
||||
chosen = saved_goal() if goal is None else goal
|
||||
log = weights()
|
||||
fats = body_fats()
|
||||
steps = daily_steps()
|
||||
sessions, stamp = activity()
|
||||
weight = log[max(log)] if log else None
|
||||
bio = profile().biometrics(weight, today) if weight is not None else None
|
||||
exercise = (
|
||||
_exercise(bio, sessions, steps, today)
|
||||
if bio is not None and (stamp is not None or steps)
|
||||
else None
|
||||
)
|
||||
return BodyView(
|
||||
profile=profile(),
|
||||
goal=chosen,
|
||||
weights=log,
|
||||
body_fats=fats,
|
||||
steps=steps,
|
||||
sessions=sessions,
|
||||
activity_stamp=stamp,
|
||||
exercise=exercise,
|
||||
numbers=(
|
||||
None
|
||||
if bio is None
|
||||
else _numbers(bio, fats[max(fats)] if fats else None, chosen, exercise)
|
||||
),
|
||||
)
|
||||
@ -27,6 +27,13 @@ from diet_guard._budget import (
|
||||
from diet_guard._budget_derived import protein_target_g
|
||||
from diet_guard._cli_args import parse_args
|
||||
from diet_guard._cli_averages import cmd_averages
|
||||
from diet_guard._cli_body import (
|
||||
cmd_body,
|
||||
cmd_bodyfat,
|
||||
cmd_plan_week,
|
||||
cmd_profile,
|
||||
cmd_weight,
|
||||
)
|
||||
from diet_guard._cli_gate import cmd_gate
|
||||
from diet_guard._cli_init import cmd_init
|
||||
from diet_guard._cli_kuchnia import cmd_kuchnia
|
||||
@ -219,6 +226,11 @@ _COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {
|
||||
"prune-peers": _dispatch_prune,
|
||||
"gate": _dispatch_gate,
|
||||
"undo": lambda _args: _cmd_undo(),
|
||||
"body": lambda args: cmd_body(_emit, args),
|
||||
"weight": lambda args: cmd_weight(_emit, args),
|
||||
"bodyfat": lambda args: cmd_bodyfat(_emit, args),
|
||||
"profile": lambda args: cmd_profile(_emit, args),
|
||||
"plan-week": lambda args: cmd_plan_week(_emit, args),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
|
||||
from diet_guard._cli_averages import register_averages_subparser
|
||||
from diet_guard._cli_body import register_body_subparsers
|
||||
from diet_guard._cli_kuchnia import register_kuchnia_subparser
|
||||
from diet_guard._cli_prune import register_prune_subparser
|
||||
from diet_guard._cli_sync import register_sync_subparser
|
||||
@ -97,6 +98,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
register_kuchnia_subparser(sub)
|
||||
register_prune_subparser(sub)
|
||||
register_sync_subparser(sub)
|
||||
register_body_subparsers(sub)
|
||||
|
||||
gate = sub.add_parser(
|
||||
"gate",
|
||||
|
||||
231
diet_guard/_cli_body.py
Normal file
231
diet_guard/_cli_body.py
Normal file
@ -0,0 +1,231 @@
|
||||
"""CLI for the Body feature: ``body``, ``weight``, ``profile``, ``plan-week``.
|
||||
|
||||
Split out of :mod:`diet_guard._cli` for the 250-line cap. Everything shown is
|
||||
informational; nothing here writes the daily budget. The one budget-record
|
||||
field touched is ``w`` (body weight, feeding the protein target), and only
|
||||
when the typed weigh-in is the newest one.
|
||||
|
||||
Edits publish straight away (``publish_after_log``), for the same reason a
|
||||
logged meal does: until the full tick runs, the phone shows stale data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._body_energy import (
|
||||
ACTIVITY_CHOICES,
|
||||
DIRECTIONS,
|
||||
TIME_UNITS,
|
||||
WEIGHT_UNITS,
|
||||
Goal,
|
||||
)
|
||||
from diet_guard._body_fat_store import (
|
||||
MAX_BODY_FAT,
|
||||
MIN_BODY_FAT,
|
||||
body_fats,
|
||||
set_body_fat,
|
||||
)
|
||||
from diet_guard._body_prefs import saved_goal, set_goal
|
||||
from diet_guard._body_report import (
|
||||
activity_lines,
|
||||
bmi_lines,
|
||||
bmr_lines,
|
||||
bodyfat_lines,
|
||||
ideal_lines,
|
||||
plan_lines,
|
||||
profile_lines,
|
||||
target_lines,
|
||||
)
|
||||
from diet_guard._body_store import (
|
||||
MAX_KG,
|
||||
MIN_KG,
|
||||
newest_weight,
|
||||
set_profile,
|
||||
set_weight,
|
||||
weights,
|
||||
)
|
||||
from diet_guard._body_view import budget_or_default, build_view
|
||||
from diet_guard._daystatus import day_total_kcal
|
||||
from diet_guard._phone_weight import update_weight
|
||||
from diet_guard._state import load_log, now_local
|
||||
from diet_guard._sync_events import publish_after_log
|
||||
from diet_guard._week_plan import WEEKDAYS, plan_week
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_WEIGHT_LIST = 14
|
||||
|
||||
|
||||
def _iso_date(text: str) -> str:
|
||||
"""Argparse type: a ``YYYY-MM-DD`` date, normalised."""
|
||||
try:
|
||||
return date.fromisoformat(text).isoformat()
|
||||
except ValueError as exc:
|
||||
msg = f"not a YYYY-MM-DD date: {text!r}"
|
||||
raise argparse.ArgumentTypeError(msg) from exc
|
||||
|
||||
|
||||
def _day_kcal(text: str) -> tuple[int, int]:
|
||||
"""Argparse type: ``mon=2500`` -> ``(0, 2500)``."""
|
||||
name, _, kcal = text.partition("=")
|
||||
names = [w.lower() for w in WEEKDAYS]
|
||||
if name.lower()[:3] not in names or not kcal.isdigit():
|
||||
msg = f"expected e.g. mon=2500, got {text!r}"
|
||||
raise argparse.ArgumentTypeError(msg)
|
||||
return names.index(name.lower()[:3]), int(kcal)
|
||||
|
||||
|
||||
def register_body_subparsers(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register ``body``, ``weight``, ``profile`` and ``plan-week``."""
|
||||
body = sub.add_parser("body", help="Calorie target, BMI, ideal weight, workouts.")
|
||||
body.add_argument("--direction", choices=DIRECTIONS, help="lose, maintain or gain.")
|
||||
body.add_argument("--amount", type=float, help="how much, e.g. 0.5.")
|
||||
body.add_argument(
|
||||
"--weight-unit", choices=list(WEIGHT_UNITS), help="kg, lb, g, st."
|
||||
)
|
||||
body.add_argument("--time-unit", choices=list(TIME_UNITS), help="per day/week/...")
|
||||
body.add_argument("--activity", choices=ACTIVITY_CHOICES, help="activity level.")
|
||||
body.add_argument("--save", action="store_true", help="remember this goal.")
|
||||
fat = sub.add_parser("bodyfat", help="Log (or list) body fat %%.")
|
||||
fat.add_argument("pct", nargs="?", type=float, help="body fat in %%.")
|
||||
fat.add_argument("--date", type=_iso_date, help="day (default today).")
|
||||
fat.add_argument("--delete", action="store_true", help="delete that day.")
|
||||
weight = sub.add_parser("weight", help="Log (or list) body weight.")
|
||||
weight.add_argument("kg", nargs="?", type=float, help="weight in kg.")
|
||||
weight.add_argument("--date", type=_iso_date, help="day (default today).")
|
||||
weight.add_argument("--delete", action="store_true", help="delete that day.")
|
||||
prof = sub.add_parser("profile", help="Set birth date, height and sex.")
|
||||
prof.add_argument("--birth", type=_iso_date, help="YYYY-MM-DD.")
|
||||
prof.add_argument("--height", type=float, help="height in cm.")
|
||||
prof.add_argument("--sex", choices=("m", "f"), help="for the BMR constant.")
|
||||
plan = sub.add_parser(
|
||||
"plan-week", help="kcal per remaining day for a weekly average."
|
||||
)
|
||||
plan.add_argument("--target", type=int, help="average kcal/day (default: budget).")
|
||||
plan.add_argument(
|
||||
"--day", type=_day_kcal, action="append", default=[], help="mon=2500"
|
||||
)
|
||||
|
||||
|
||||
def _publish(emit: Callable[[str], None]) -> None:
|
||||
"""Push the edit now, reporting (not raising) an outage."""
|
||||
failure = publish_after_log()
|
||||
if failure is not None:
|
||||
emit(f"saved locally, not yet published ({failure}).")
|
||||
|
||||
|
||||
def _goal(args: argparse.Namespace) -> Goal:
|
||||
"""The saved goal with any typed part replaced."""
|
||||
goal = saved_goal()
|
||||
parts = {
|
||||
"direction": args.direction,
|
||||
"amount": args.amount,
|
||||
"weight_unit": args.weight_unit,
|
||||
"time_unit": args.time_unit,
|
||||
"activity": args.activity,
|
||||
}
|
||||
return replace(goal, **{k: v for k, v in parts.items() if v is not None})
|
||||
|
||||
|
||||
def cmd_body(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
||||
"""Print every Body block: the target first, profile and body fat last.
|
||||
|
||||
The profile is set up once, so it sits at the bottom -- unless something
|
||||
in it is missing, when it comes first so the gap is the first thing seen.
|
||||
"""
|
||||
today = now_local().date()
|
||||
goal = _goal(args)
|
||||
if args.save:
|
||||
set_goal(goal)
|
||||
emit("goal saved.")
|
||||
view = build_view(today, goal)
|
||||
blocks = [
|
||||
target_lines(view),
|
||||
bmi_lines(view),
|
||||
ideal_lines(view),
|
||||
bmr_lines(view),
|
||||
activity_lines(view),
|
||||
]
|
||||
profile = profile_lines(view, today)
|
||||
blocks = [profile, *blocks] if not view.profile.complete else [*blocks, profile]
|
||||
blocks.append(bodyfat_lines(view))
|
||||
for block in blocks:
|
||||
for line in block:
|
||||
emit(line)
|
||||
emit("")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_bodyfat(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
||||
"""Log, delete or list body-fat readings."""
|
||||
day = args.date or now_local().date().isoformat()
|
||||
if args.pct is None and not args.delete:
|
||||
for when, pct in list(body_fats().items())[-_WEIGHT_LIST:]:
|
||||
emit(f"{when} {pct:.1f}%")
|
||||
return 0
|
||||
if args.pct is not None and not MIN_BODY_FAT <= args.pct <= MAX_BODY_FAT:
|
||||
emit(f"body fat must be between {MIN_BODY_FAT:g} and {MAX_BODY_FAT:g} %.")
|
||||
return 1
|
||||
set_body_fat(day, None if args.delete else args.pct)
|
||||
emit(f"{'deleted' if args.delete else f'{args.pct:g}%'} for {day}.")
|
||||
_publish(emit)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_weight(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
||||
"""Log, delete or list weigh-ins."""
|
||||
day = args.date or now_local().date().isoformat()
|
||||
if args.kg is None and not args.delete:
|
||||
for when, kg in list(weights().items())[-_WEIGHT_LIST:]:
|
||||
emit(f"{when} {kg:.1f} kg")
|
||||
return 0
|
||||
if args.kg is not None and not MIN_KG <= args.kg <= MAX_KG:
|
||||
emit(f"weight must be between {MIN_KG:g} and {MAX_KG:g} kg.")
|
||||
return 1
|
||||
set_weight(day, None if args.delete else args.kg)
|
||||
newest = newest_weight()
|
||||
# Editing (or deleting) the newest day moves ``w``; an older day does not.
|
||||
if newest is not None and day >= newest[0]:
|
||||
update_weight(newest[1])
|
||||
emit(f"{'deleted' if args.delete else f'{args.kg:g} kg'} for {day}.")
|
||||
_publish(emit)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_profile(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
||||
"""Set any of birth/height/sex."""
|
||||
fields = {
|
||||
name: value
|
||||
for name, value in (
|
||||
("birth", args.birth),
|
||||
("height_cm", args.height),
|
||||
("sex", args.sex),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
if not fields:
|
||||
emit(profile_lines(build_view(now_local().date()), now_local().date())[0])
|
||||
return 0
|
||||
set_profile(**fields)
|
||||
emit("profile saved.")
|
||||
_publish(emit)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_plan_week(emit: Callable[[str], None], args: argparse.Namespace) -> int:
|
||||
"""Print the week plan."""
|
||||
log = load_log()
|
||||
plan = plan_week(
|
||||
now_local().date(),
|
||||
args.target or budget_or_default(),
|
||||
{day: day_total_kcal(log, day) for day in log},
|
||||
dict(args.day),
|
||||
)
|
||||
for line in plan_lines(plan):
|
||||
emit(line)
|
||||
return 0
|
||||
@ -4,17 +4,20 @@ Split out of :mod:`diet_guard._cli` to hold the repo's 250-line cap, following
|
||||
the same thin-per-subcommand-handler shape as ``_cli_gate.py`` (``gate``),
|
||||
``_cli_log.py`` (``ate``) and ``_cli_sync.py`` (``sync``).
|
||||
|
||||
The biometrics prompted for here are used **once** and discarded: only the
|
||||
computed budget (and the body weight, which the protein target needs) is ever
|
||||
persisted. See ``CLAUDE.md``'s "Biometrics are used once and discarded".
|
||||
The biometrics prompted for here seed the Body profile too (height, sex, a
|
||||
birth date estimated from the typed age, and today's weigh-in), so BMI and
|
||||
ideal weight work straight after ``init``. Refine the birth date with
|
||||
``diet_guard profile --birth``. See ``docs/DOCS-body.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._body_store import set_profile, set_weight
|
||||
from diet_guard._budget import write_budget
|
||||
from diet_guard._budget_biometrics import Biometrics, compute_target_budget
|
||||
from diet_guard._state import now_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -81,6 +84,15 @@ def cmd_init(
|
||||
deficit_kcal=deficit,
|
||||
)
|
||||
write_budget(budget, weight_kg=bio.weight_kg)
|
||||
today = now_local().date()
|
||||
# Same month/day as today, so the estimated age is exact right now.
|
||||
birth = today.replace(year=today.year - int(bio.age_years), day=min(today.day, 28))
|
||||
set_profile(
|
||||
birth=birth.isoformat(),
|
||||
height_cm=bio.height_cm,
|
||||
sex="m" if bio.is_male else "f",
|
||||
)
|
||||
set_weight(today.isoformat(), bio.weight_kg, "init")
|
||||
emit(f"budget computed from your biometrics: {budget:g} kcal/day.")
|
||||
emit("edit it any time from the gate's calendar tab or the phone app.")
|
||||
return 0
|
||||
|
||||
@ -37,6 +37,10 @@ FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json"
|
||||
# sync in their own right. Kept separate from FOOD_BANK_FILE because that
|
||||
# one is rewritten wholesale on every log write.
|
||||
MANUAL_BANK_FILE: Path = DATA_DIR / "food_bank_manual.json"
|
||||
# Body profile (birth date, height, sex), the per-day weight log and the
|
||||
# PC-published workout sessions (see _body_store.py, docs/DOCS-body.md).
|
||||
# Synced as its own document; git-ignored, never committed.
|
||||
BODY_FILE: Path = DATA_DIR / "body.json"
|
||||
# The budget: a plain JSON dotfile alongside the log, freely editable on this
|
||||
# device or the phone app and synced between them (see _sync.py).
|
||||
# Git-ignored, never committed. "Hidden" here means never-online (it lives
|
||||
|
||||
241
diet_guard/_gatelock_body.py
Normal file
241
diet_guard/_gatelock_body.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""The gate's Body tab: calorie goal, weight/body-fat logs + graph, BMI.
|
||||
|
||||
A self-contained component, not another link in ``MealGate``'s class chain:
|
||||
that chain sits at pylint's max-parents cap (see the ``_gatelock_calendar``
|
||||
notes), and the tab shares no state with the meal form. ``_build_tabs``
|
||||
constructs one :class:`BodyTab` per monitor; it keeps itself alive through
|
||||
the Tk callbacks it registers.
|
||||
|
||||
Everything shown is informational -- no button here writes the budget. Saving
|
||||
a weight, body fat, the profile or the goal writes the Body document
|
||||
(and ``w``, when the weight is the newest), then publishes on a background
|
||||
thread so a slow network can never freeze a lock screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import ScrollableSurface
|
||||
|
||||
from diet_guard._body_fat_store import MAX_BODY_FAT, MIN_BODY_FAT, set_body_fat
|
||||
from diet_guard._body_prefs import saved_goal, set_goal
|
||||
from diet_guard._body_report import (
|
||||
activity_lines,
|
||||
bmi_lines,
|
||||
bmr_lines,
|
||||
bodyfat_lines,
|
||||
ideal_lines,
|
||||
plan_lines,
|
||||
profile_lines,
|
||||
target_lines,
|
||||
)
|
||||
from diet_guard._body_store import (
|
||||
MAX_KG,
|
||||
MIN_KG,
|
||||
newest_weight,
|
||||
set_profile,
|
||||
set_weight,
|
||||
)
|
||||
from diet_guard._body_view import BodyView, budget_or_default, build_view
|
||||
from diet_guard._daystatus import day_total_kcal
|
||||
from diet_guard._gatelock_body_graph import GraphBox, draw_weight_graph, in_range
|
||||
from diet_guard._gatelock_body_input import (
|
||||
HEIGHT_CM,
|
||||
goal_choice,
|
||||
load_goal,
|
||||
parse_day,
|
||||
parse_float,
|
||||
)
|
||||
from diet_guard._gatelock_body_vars import BodyCallbacks, BodyVars
|
||||
from diet_guard._gatelock_body_widgets import GRAPH_H, GRAPH_W, build_body_frame
|
||||
from diet_guard._gatelock_calendar_types import _COLORS
|
||||
from diet_guard._phone_weight import update_weight
|
||||
from diet_guard._state import load_log, now_local
|
||||
from diet_guard._sync_events import publish_after_log_detached
|
||||
from diet_guard._week_plan import plan_week
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
import tkinter as tk
|
||||
|
||||
from diet_guard._week_plan import WeekPlan
|
||||
|
||||
__all__ = ["BodyTab"]
|
||||
|
||||
_DAY_HINT = "Type the YYYY-MM-DD day to delete."
|
||||
|
||||
|
||||
class BodyTab:
|
||||
"""One monitor's Body tab."""
|
||||
|
||||
def __init__(self, notebook: tk.Misc) -> None:
|
||||
"""Build the tab into ``notebook`` and render it once."""
|
||||
self._budget = budget_or_default()
|
||||
self._days: int | None = 90
|
||||
self._goal = saved_goal()
|
||||
self.vars = BodyVars()
|
||||
callbacks = BodyCallbacks(
|
||||
save_profile=self.save_profile,
|
||||
save_weight=self.save_weight,
|
||||
delete_weight=self.delete_weight,
|
||||
save_body_fat=self.save_body_fat,
|
||||
delete_body_fat=self.delete_body_fat,
|
||||
goal_changed=self.goal_changed,
|
||||
set_range=self.set_range,
|
||||
recalc=self.refresh,
|
||||
)
|
||||
view = build_view(now_local().date())
|
||||
surface = ScrollableSurface(notebook, _COLORS)
|
||||
widgets = build_body_frame(
|
||||
surface.content,
|
||||
self.vars,
|
||||
callbacks,
|
||||
profile_first=not view.profile.complete,
|
||||
)
|
||||
widgets.frame.pack(fill="both", expand=True)
|
||||
surface.finalize()
|
||||
self.container = surface.container
|
||||
self._canvas = widgets.canvas
|
||||
self._load_inputs(view)
|
||||
self.refresh()
|
||||
|
||||
def _load_inputs(self, view: BodyView) -> None:
|
||||
"""Seed the input fields and table choice from what is stored."""
|
||||
prof, logs = self.vars.profile, self.vars.logs
|
||||
prof.birth.set(view.profile.birth or "")
|
||||
height = view.profile.height_cm
|
||||
prof.height.set(f"{height:g}" if height else "")
|
||||
prof.sex.set(view.profile.sex or "")
|
||||
today = now_local().date().isoformat()
|
||||
logs.weight_day.set(today)
|
||||
logs.fat_day.set(today)
|
||||
self.vars.plan.target.set(str(self._budget))
|
||||
load_goal(self.vars.goal, self._goal)
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Recompute every block and redraw the graph from local state."""
|
||||
today = now_local().date()
|
||||
view = build_view(today, self._goal)
|
||||
target = None if view.numbers is None else view.numbers.target
|
||||
kcal = None if target is None else target.kcal
|
||||
self.vars.goal.answer.set("" if kcal is None else f"eat {kcal} kcal")
|
||||
lines = target_lines(view)
|
||||
texts = {
|
||||
"summary": profile_lines(view, today),
|
||||
"table": lines[1:] if kcal is not None else lines,
|
||||
"bodyfat": bodyfat_lines(view),
|
||||
"bmi": bmi_lines(view),
|
||||
"ideal": ideal_lines(view),
|
||||
"bmr": bmr_lines(view),
|
||||
"workouts": activity_lines(view),
|
||||
"plan": plan_lines(self._plan(today)),
|
||||
}
|
||||
for name, lines in texts.items():
|
||||
self.vars.blocks[name].set("\n".join(lines))
|
||||
self._draw(view, today)
|
||||
|
||||
def _plan(self, today: date) -> WeekPlan:
|
||||
log = load_log()
|
||||
typed = {
|
||||
index: int(value)
|
||||
for index, var in enumerate(self.vars.plan.days)
|
||||
if (value := parse_float(var.get())) is not None
|
||||
}
|
||||
target = parse_float(self.vars.plan.target.get())
|
||||
return plan_week(
|
||||
today,
|
||||
int(target) if target else self._budget,
|
||||
{day: day_total_kcal(log, day) for day in log},
|
||||
typed,
|
||||
)
|
||||
|
||||
def _draw(self, view: BodyView, today: date) -> None:
|
||||
fat = self.vars.logs.graph.get() == "fat"
|
||||
values = view.body_fats if fat else view.weights
|
||||
healthy = None if fat or view.numbers is None else view.numbers.healthy
|
||||
box = GraphBox(GRAPH_W, GRAPH_H)
|
||||
draw_weight_graph(
|
||||
self._canvas, in_range(values, today, self._days), box, healthy
|
||||
)
|
||||
|
||||
def set_range(self, days: int | None) -> None:
|
||||
"""Show the last ``days`` days of the graph (None = all)."""
|
||||
self._days = days
|
||||
self.refresh()
|
||||
|
||||
def _saved(self, message: str) -> None:
|
||||
self.vars.status.set(message)
|
||||
publish_after_log_detached(
|
||||
lambda why: self.vars.status.set(f"Saved; not yet synced ({why}).")
|
||||
)
|
||||
self.refresh()
|
||||
|
||||
def goal_changed(self) -> None:
|
||||
"""Re-answer and remember (synced) the goal the pickers now show."""
|
||||
self._goal = goal_choice(self.vars.goal, self._goal)
|
||||
load_goal(self.vars.goal, self._goal)
|
||||
set_goal(self._goal)
|
||||
self._saved("Goal saved.")
|
||||
|
||||
def save_profile(self) -> None:
|
||||
"""Validate and store birth date, height and sex."""
|
||||
prof = self.vars.profile
|
||||
birth, height = parse_day(prof.birth.get()), parse_float(prof.height.get())
|
||||
sex = prof.sex.get()
|
||||
if (
|
||||
birth is None
|
||||
or height is None
|
||||
or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1]
|
||||
or sex not in {"m", "f"}
|
||||
):
|
||||
self.vars.status.set("Profile needs a YYYY-MM-DD birth date, cm and sex.")
|
||||
return
|
||||
set_profile(birth=birth, height_cm=height, sex=sex)
|
||||
self._saved("Profile saved -- it moves to the bottom next time.")
|
||||
|
||||
def save_weight(self) -> None:
|
||||
"""Validate and store the typed weigh-in."""
|
||||
kg = parse_float(self.vars.logs.weight.get())
|
||||
day = parse_day(self.vars.logs.weight_day.get())
|
||||
if kg is None or day is None or not MIN_KG <= kg <= MAX_KG:
|
||||
self.vars.status.set(f"Weight needs {MIN_KG:g}-{MAX_KG:g} kg and a day.")
|
||||
return
|
||||
self._store_weight(day, kg)
|
||||
self._saved(f"Saved {kg:g} kg for {day}.")
|
||||
|
||||
def delete_weight(self) -> None:
|
||||
"""Delete the weigh-in for the typed day."""
|
||||
day = parse_day(self.vars.logs.weight_day.get())
|
||||
if day is None:
|
||||
self.vars.status.set(_DAY_HINT)
|
||||
return
|
||||
self._store_weight(day, None)
|
||||
self._saved(f"Deleted the weigh-in for {day}.")
|
||||
|
||||
@staticmethod
|
||||
def _store_weight(day: str, kg: float | None) -> None:
|
||||
set_weight(day, kg)
|
||||
newest = newest_weight()
|
||||
if newest is not None and day >= newest[0]:
|
||||
update_weight(newest[1])
|
||||
|
||||
def save_body_fat(self) -> None:
|
||||
"""Validate and store the typed body-fat reading."""
|
||||
pct = parse_float(self.vars.logs.body_fat.get())
|
||||
day = parse_day(self.vars.logs.fat_day.get())
|
||||
if pct is None or day is None or not MIN_BODY_FAT <= pct <= MAX_BODY_FAT:
|
||||
limits = f"{MIN_BODY_FAT:g}-{MAX_BODY_FAT:g}%"
|
||||
self.vars.status.set(f"Body fat needs {limits} and a day.")
|
||||
return
|
||||
set_body_fat(day, pct)
|
||||
self._saved(f"Saved {pct:g}% body fat for {day}.")
|
||||
|
||||
def delete_body_fat(self) -> None:
|
||||
"""Delete the body-fat reading for the typed day."""
|
||||
day = parse_day(self.vars.logs.fat_day.get())
|
||||
if day is None:
|
||||
self.vars.status.set(_DAY_HINT)
|
||||
return
|
||||
set_body_fat(day, None)
|
||||
self._saved(f"Deleted the body-fat reading for {day}.")
|
||||
186
diet_guard/_gatelock_body_graph.py
Normal file
186
diet_guard/_gatelock_body_graph.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""The Body tab's weight graph, drawn on a plain ``tk.Canvas``.
|
||||
|
||||
No plotting dependency: the gate runs on system ``/usr/bin/python`` and a
|
||||
line, some dots, four gridlines and a shaded healthy band are all a weight
|
||||
trend needs. The geometry (:func:`graph_points`, :func:`value_ticks`) is pure
|
||||
so it is tested without Tk; :func:`draw_weight_graph` only issues canvas
|
||||
calls.
|
||||
|
||||
.. note::
|
||||
Imports ``tkinter`` for types only; it never builds widgets, so it is not
|
||||
in ``_GATE_TK_MODULES``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _MUTED
|
||||
from diet_guard._gatelock_typography import CAPTION
|
||||
from diet_guard._gatelock_ui import BG, FG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tkinter as tk
|
||||
|
||||
__all__ = [
|
||||
"GRAPH_RANGES",
|
||||
"GraphBox",
|
||||
"draw_weight_graph",
|
||||
"graph_points",
|
||||
"value_ticks",
|
||||
]
|
||||
|
||||
# (label, days back; None = everything).
|
||||
GRAPH_RANGES: tuple[tuple[str, int | None], ...] = (
|
||||
("30 days", 30),
|
||||
("90 days", 90),
|
||||
("1 year", 365),
|
||||
("All", None),
|
||||
)
|
||||
_HEALTHY_FILL = "#1f3a2a"
|
||||
_GRID = "#33363d"
|
||||
_DOT_R = 3
|
||||
_TICKS = 4
|
||||
# Keep a flat line off the frame: pad the value range by this much (kg).
|
||||
_MIN_SPAN_KG = 2.0
|
||||
# Include a healthy-band edge on the axis only when it is this close (kg).
|
||||
_BAND_REACH_KG = 5.0
|
||||
# Headroom above and below the data, as a fraction of its range.
|
||||
_PAD_FRACTION = 0.1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphBox:
|
||||
"""Canvas size and the inner plot margins (px)."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
left: int = 56
|
||||
right: int = 16
|
||||
top: int = 16
|
||||
bottom: int = 28
|
||||
|
||||
def x(self, frac: float) -> float:
|
||||
"""Canvas x for a 0..1 position along the time axis."""
|
||||
return self.left + frac * (self.width - self.left - self.right)
|
||||
|
||||
def y(self, frac: float) -> float:
|
||||
"""Canvas y for a 0..1 position up the value axis."""
|
||||
return self.height - self.bottom - frac * (self.height - self.top - self.bottom)
|
||||
|
||||
|
||||
def in_range(
|
||||
weights: dict[str, float], today: date, days: int | None
|
||||
) -> dict[str, float]:
|
||||
"""The weigh-ins within the last ``days`` days (all when None)."""
|
||||
if days is None:
|
||||
return dict(weights)
|
||||
first = (today - timedelta(days=days)).isoformat()
|
||||
return {day: kg for day, kg in weights.items() if day >= first}
|
||||
|
||||
|
||||
def value_span(
|
||||
values: list[float], band: tuple[float, float] | None
|
||||
) -> tuple[float, float]:
|
||||
"""Low/high of the value axis: the data, padded, plus a *nearby* band edge.
|
||||
|
||||
A band edge more than :data:`_BAND_REACH_KG` from the data is left off the
|
||||
axis: pulling a 60 kg floor under an 84-to-82 kg trend flattens the trend
|
||||
into one line, and the trend is what the graph is for.
|
||||
"""
|
||||
low, high = min(values), max(values)
|
||||
if band is not None:
|
||||
for edge in band:
|
||||
if low - _BAND_REACH_KG <= edge <= high + _BAND_REACH_KG:
|
||||
low, high = min(low, edge), max(high, edge)
|
||||
pad = max((high - low) * _PAD_FRACTION, _MIN_SPAN_KG / 2)
|
||||
return low - pad, high + pad
|
||||
|
||||
|
||||
def graph_points(
|
||||
weights: dict[str, float], box: GraphBox, span: tuple[float, float]
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Canvas points for each weigh-in, time on x (by date), kg on y."""
|
||||
days = sorted(weights)
|
||||
first = date.fromisoformat(days[0])
|
||||
total = max((date.fromisoformat(days[-1]) - first).days, 1)
|
||||
low, high = span
|
||||
return [
|
||||
(
|
||||
box.x(
|
||||
(date.fromisoformat(day) - first).days / total if len(days) > 1 else 0.5
|
||||
),
|
||||
box.y((weights[day] - low) / (high - low)),
|
||||
)
|
||||
for day in days
|
||||
]
|
||||
|
||||
|
||||
def value_ticks(span: tuple[float, float]) -> list[float]:
|
||||
"""Evenly spaced gridline values across the axis, ends included."""
|
||||
low, high = span
|
||||
return [low + (high - low) * i / _TICKS for i in range(_TICKS + 1)]
|
||||
|
||||
|
||||
def _draw_band(
|
||||
canvas: tk.Canvas,
|
||||
box: GraphBox,
|
||||
span: tuple[float, float],
|
||||
healthy: tuple[float, float],
|
||||
) -> None:
|
||||
"""Shade the healthy-BMI weights, clipped to the axis (skipped if off it)."""
|
||||
low, high = span
|
||||
if healthy[0] >= high or healthy[1] <= low:
|
||||
return
|
||||
top = box.y((min(healthy[1], high) - low) / (high - low))
|
||||
bottom = box.y((max(healthy[0], low) - low) / (high - low))
|
||||
canvas.create_rectangle(
|
||||
box.x(0), top, box.x(1), bottom, fill=_HEALTHY_FILL, outline=""
|
||||
)
|
||||
font = (_COLORS.typography.font_family, CAPTION)
|
||||
canvas.create_text(
|
||||
box.x(1) - 4, top + 2, text="healthy BMI", anchor="ne", fill=_MUTED, font=font
|
||||
)
|
||||
|
||||
|
||||
def draw_weight_graph(
|
||||
canvas: tk.Canvas,
|
||||
weights: dict[str, float],
|
||||
box: GraphBox,
|
||||
healthy: tuple[float, float] | None,
|
||||
) -> None:
|
||||
"""Redraw the whole graph; an empty log shows a hint instead."""
|
||||
canvas.delete("all")
|
||||
font = (_COLORS.typography.font_family, CAPTION)
|
||||
if not weights:
|
||||
canvas.create_text(
|
||||
box.width / 2,
|
||||
box.height / 2,
|
||||
text="Nothing logged in this range yet.",
|
||||
fill=_MUTED,
|
||||
font=font,
|
||||
)
|
||||
return
|
||||
span = value_span(list(weights.values()), healthy)
|
||||
low, high = span
|
||||
if healthy is not None:
|
||||
_draw_band(canvas, box, span, healthy)
|
||||
for value in value_ticks(span):
|
||||
y = box.y((value - low) / (high - low))
|
||||
canvas.create_line(box.x(0), y, box.x(1), y, fill=_GRID)
|
||||
canvas.create_text(
|
||||
box.left - 6, y, text=f"{value:.1f}", anchor="e", fill=_MUTED, font=font
|
||||
)
|
||||
points = graph_points(weights, box, span)
|
||||
if len(points) > 1:
|
||||
canvas.create_line(*[c for p in points for c in p], fill=_ACCENT, width=2)
|
||||
for x, y in points:
|
||||
canvas.create_oval(
|
||||
x - _DOT_R, y - _DOT_R, x + _DOT_R, y + _DOT_R, fill=_ACCENT, outline=BG
|
||||
)
|
||||
days = sorted(weights)
|
||||
base = box.height - box.bottom + 14
|
||||
canvas.create_text(box.x(0), base, text=days[0], anchor="w", fill=FG, font=font)
|
||||
canvas.create_text(box.x(1), base, text=days[-1], anchor="e", fill=FG, font=font)
|
||||
62
diet_guard/_gatelock_body_input.py
Normal file
62
diet_guard/_gatelock_body_input.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Parse and seed the Body tab's inputs -- pure over the tab's variables.
|
||||
|
||||
Split from :mod:`diet_guard._gatelock_body` for the 250-line cap. No widget
|
||||
is built here; the functions only read and write ``StringVar``-likes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._body_energy import ACTIVITY_CHOICES, activity_label
|
||||
from diet_guard._body_prefs import goal_from_json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._body_energy import Goal
|
||||
from diet_guard._gatelock_body_vars import GoalVars
|
||||
|
||||
__all__ = ["HEIGHT_CM", "goal_choice", "load_goal", "parse_day", "parse_float"]
|
||||
|
||||
# Outside this range (cm) a height is a typo rather than a person.
|
||||
HEIGHT_CM = (50.0, 260.0)
|
||||
|
||||
|
||||
def parse_float(text: str) -> float | None:
|
||||
"""``text`` as a float (comma decimals accepted), or None."""
|
||||
try:
|
||||
return float(text.strip().replace(",", "."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_day(text: str) -> str | None:
|
||||
"""``text`` as a normalised ``YYYY-MM-DD``, or None."""
|
||||
try:
|
||||
return date.fromisoformat(text.strip()).isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def load_goal(goal_vars: GoalVars, goal: Goal) -> None:
|
||||
"""Put ``goal`` into the sentence's pickers."""
|
||||
goal_vars.direction.set(goal.direction)
|
||||
goal_vars.amount.set(f"{goal.amount:g}")
|
||||
goal_vars.weight_unit.set(goal.weight_unit)
|
||||
goal_vars.time_unit.set(goal.time_unit)
|
||||
goal_vars.activity.set(activity_label(goal.activity))
|
||||
|
||||
|
||||
def goal_choice(goal_vars: GoalVars, fallback: Goal) -> Goal:
|
||||
"""The goal the pickers show; an unreadable amount keeps ``fallback``'s."""
|
||||
labels = {activity_label(key): key for key in ACTIVITY_CHOICES}
|
||||
amount = parse_float(goal_vars.amount.get())
|
||||
return goal_from_json(
|
||||
{
|
||||
"dir": goal_vars.direction.get(),
|
||||
"amt": fallback.amount if amount is None or amount < 0 else amount,
|
||||
"wu": goal_vars.weight_unit.get(),
|
||||
"tu": goal_vars.time_unit.get(),
|
||||
"act": labels.get(goal_vars.activity.get(), fallback.activity),
|
||||
}
|
||||
)
|
||||
122
diet_guard/_gatelock_body_vars.py
Normal file
122
diet_guard/_gatelock_body_vars.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""The Body tab's variables and callback bundle.
|
||||
|
||||
Split from :mod:`diet_guard._gatelock_body_widgets` for the 250-line cap, and
|
||||
grouped into small bundles (profile, logs, goal, plan) rather than one flat
|
||||
record, so each stays under pylint's instance-attribute limit.
|
||||
|
||||
.. note::
|
||||
Creates ``tkinter`` variables, so it **must** appear in
|
||||
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._week_plan import WEEKDAYS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
__all__ = [
|
||||
"BLOCKS",
|
||||
"BodyCallbacks",
|
||||
"BodyVars",
|
||||
"BodyWidgets",
|
||||
]
|
||||
|
||||
# The read-only text panels, each a monospace block the controller fills.
|
||||
BLOCKS: tuple[str, ...] = (
|
||||
"summary",
|
||||
"table",
|
||||
"bodyfat",
|
||||
"bmi",
|
||||
"ideal",
|
||||
"bmr",
|
||||
"workouts",
|
||||
"plan",
|
||||
)
|
||||
|
||||
|
||||
def _var(value: str = "") -> tk.StringVar:
|
||||
return tk.StringVar(value=value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileVars:
|
||||
"""Birth date, height and sex inputs."""
|
||||
|
||||
birth: tk.StringVar = field(default_factory=_var)
|
||||
height: tk.StringVar = field(default_factory=_var)
|
||||
sex: tk.StringVar = field(default_factory=_var)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogVars:
|
||||
"""The weight and body-fat inputs, and which one the graph shows."""
|
||||
|
||||
weight: tk.StringVar = field(default_factory=_var)
|
||||
weight_day: tk.StringVar = field(default_factory=_var)
|
||||
body_fat: tk.StringVar = field(default_factory=_var)
|
||||
fat_day: tk.StringVar = field(default_factory=_var)
|
||||
graph: tk.StringVar = field(default_factory=lambda: _var("weight"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoalVars:
|
||||
"""The goal sentence's five choices (activity holds its menu label)."""
|
||||
|
||||
direction: tk.StringVar = field(default_factory=_var)
|
||||
amount: tk.StringVar = field(default_factory=_var)
|
||||
weight_unit: tk.StringVar = field(default_factory=_var)
|
||||
time_unit: tk.StringVar = field(default_factory=_var)
|
||||
activity: tk.StringVar = field(default_factory=_var)
|
||||
answer: tk.StringVar = field(default_factory=_var)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanVars:
|
||||
"""The week planner's target and per-day overrides."""
|
||||
|
||||
target: tk.StringVar = field(default_factory=_var)
|
||||
days: tuple[tk.StringVar, ...] = field(
|
||||
default_factory=lambda: tuple(_var() for _ in WEEKDAYS)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BodyVars:
|
||||
"""Every input bundle, the status line and the text panels."""
|
||||
|
||||
profile: ProfileVars = field(default_factory=ProfileVars)
|
||||
logs: LogVars = field(default_factory=LogVars)
|
||||
goal: GoalVars = field(default_factory=GoalVars)
|
||||
plan: PlanVars = field(default_factory=PlanVars)
|
||||
status: tk.StringVar = field(default_factory=_var)
|
||||
blocks: dict[str, tk.StringVar] = field(
|
||||
default_factory=lambda: {name: _var() for name in BLOCKS}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BodyCallbacks:
|
||||
"""What the tab's buttons do."""
|
||||
|
||||
save_profile: Callable[[], None]
|
||||
save_weight: Callable[[], None]
|
||||
delete_weight: Callable[[], None]
|
||||
save_body_fat: Callable[[], None]
|
||||
delete_body_fat: Callable[[], None]
|
||||
goal_changed: Callable[[], None]
|
||||
set_range: Callable[[int | None], None]
|
||||
recalc: Callable[[], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BodyWidgets:
|
||||
"""The widgets the controller touches after building."""
|
||||
|
||||
frame: tk.Frame
|
||||
canvas: tk.Canvas
|
||||
220
diet_guard/_gatelock_body_widgets.py
Normal file
220
diet_guard/_gatelock_body_widgets.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""Widget builders for the gate's Body tab.
|
||||
|
||||
Split from :mod:`diet_guard._gatelock_body` (the controller) for the 250-line
|
||||
cap. Everything here only builds and packs; every value lives in
|
||||
:class:`~diet_guard._gatelock_body_vars.BodyVars` and every action is a
|
||||
callback.
|
||||
|
||||
Layout, top to bottom: the goal sentence ("To lose 0.5 kg / week on
|
||||
Moderate eat 2019 kcal", every choice a drop-down), then weight with the
|
||||
graph beside BMI, ideal weight and resting burn, then workouts and the week
|
||||
plan, then the profile (set up once -- first instead while incomplete) and
|
||||
last the body-fat log.
|
||||
|
||||
.. note::
|
||||
Builds ``tkinter`` widgets, so it **must** appear in
|
||||
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import ButtonStyle, make_button
|
||||
|
||||
from diet_guard._body_energy import (
|
||||
ACTIVITY_CHOICES,
|
||||
DIRECTIONS,
|
||||
PRESET_AMOUNTS,
|
||||
TIME_UNITS,
|
||||
WEIGHT_UNITS,
|
||||
activity_label,
|
||||
)
|
||||
from diet_guard._gatelock_body_graph import GRAPH_RANGES
|
||||
from diet_guard._gatelock_body_vars import BodyWidgets
|
||||
from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _FIELD_BG, _MUTED
|
||||
from diet_guard._gatelock_spacing import MD, SM, XS
|
||||
from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE, TITLE
|
||||
from diet_guard._gatelock_ui import BG, FG
|
||||
from diet_guard._week_plan import WEEKDAYS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from diet_guard._gatelock_body_vars import BodyCallbacks, BodyVars
|
||||
|
||||
__all__ = ["GRAPH_H", "GRAPH_W", "build_body_frame"]
|
||||
|
||||
GRAPH_W = 720
|
||||
GRAPH_H = 300
|
||||
_MONO = ("Courier", LABEL)
|
||||
_SECONDARY = ButtonStyle(variant="secondary", bold=False)
|
||||
_TOGGLE = {"bg": BG, "fg": FG, "selectcolor": _FIELD_BG, "activebackground": BG}
|
||||
|
||||
|
||||
def _font(size: int = LABEL) -> tuple[str, int]:
|
||||
return (_COLORS.typography.font_family, size)
|
||||
|
||||
|
||||
def _label(parent: tk.Misc, text: str, size: int = LABEL) -> None:
|
||||
tk.Label(parent, text=text, font=_font(size), bg=BG, fg=FG).pack(
|
||||
side="left", padx=(0, XS)
|
||||
)
|
||||
|
||||
|
||||
def _entry(parent: tk.Misc, var: tk.StringVar, width: int, size: int = BODY) -> None:
|
||||
tk.Entry(
|
||||
parent,
|
||||
textvariable=var,
|
||||
width=width,
|
||||
font=_font(size),
|
||||
bg=_FIELD_BG,
|
||||
fg=FG,
|
||||
insertbackground=FG,
|
||||
).pack(side="left", padx=(0, SM))
|
||||
|
||||
|
||||
def _row(parent: tk.Misc) -> tk.Frame:
|
||||
row = tk.Frame(parent, bg=BG)
|
||||
row.pack(anchor="w", pady=(XS, XS))
|
||||
return row
|
||||
|
||||
|
||||
def _block(parent: tk.Misc, var: tk.StringVar) -> None:
|
||||
tk.Label(
|
||||
parent, textvariable=var, font=_MONO, bg=BG, fg=FG, justify="left", anchor="w"
|
||||
).pack(anchor="w", pady=(SM, 0))
|
||||
|
||||
|
||||
def _button(parent: tk.Misc, text: str, command: Callable[[], None]) -> None:
|
||||
make_button(parent, _COLORS, text, command, _SECONDARY).pack(
|
||||
side="left", padx=(0, SM)
|
||||
)
|
||||
|
||||
|
||||
def _menu(
|
||||
parent: tk.Misc, var: tk.StringVar, values: list[str], cb: BodyCallbacks
|
||||
) -> None:
|
||||
"""A drop-down that re-answers the goal whenever a value is picked."""
|
||||
menu = tk.OptionMenu(parent, var, *values, command=lambda _v: cb.goal_changed())
|
||||
menu.config(font=_font(SUBTITLE), bg=_FIELD_BG, fg=_ACCENT, highlightthickness=0)
|
||||
menu.pack(side="left", padx=(0, XS))
|
||||
|
||||
|
||||
def _build_goal(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
|
||||
"""``To [lose] [0.5][v] [kg] / [week] on [Moderate] eat 2019 kcal``."""
|
||||
goal = vars_.goal
|
||||
row = _row(parent)
|
||||
_label(row, "To", SUBTITLE)
|
||||
_menu(row, goal.direction, list(DIRECTIONS), cb)
|
||||
_entry(row, goal.amount, 5, SUBTITLE)
|
||||
_menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb)
|
||||
_menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb)
|
||||
_label(row, "/", SUBTITLE)
|
||||
_menu(row, goal.time_unit, list(TIME_UNITS), cb)
|
||||
_label(row, "on", SUBTITLE)
|
||||
_menu(row, goal.activity, [activity_label(k) for k in ACTIVITY_CHOICES], cb)
|
||||
tk.Label(
|
||||
row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT
|
||||
).pack(side="left", padx=(MD, 0))
|
||||
_button(row, "Apply typed amount", cb.goal_changed)
|
||||
_block(parent, vars_.blocks["table"])
|
||||
|
||||
|
||||
def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
|
||||
row = _row(parent)
|
||||
_label(row, "Profile -- born (YYYY-MM-DD)")
|
||||
_entry(row, vars_.profile.birth, 11)
|
||||
_label(row, "Height cm")
|
||||
_entry(row, vars_.profile.height, 6)
|
||||
sex = vars_.profile.sex
|
||||
for value, text in (("m", "male"), ("f", "female")):
|
||||
tk.Radiobutton(row, text=text, value=value, variable=sex, **_TOGGLE).pack(
|
||||
side="left"
|
||||
)
|
||||
_button(row, "Save profile", cb.save_profile)
|
||||
_block(parent, vars_.blocks["summary"])
|
||||
|
||||
|
||||
def _dated_row(
|
||||
parent: tk.Misc,
|
||||
label: str,
|
||||
vars_pair: tuple[tk.StringVar, tk.StringVar],
|
||||
actions: tuple[Callable[[], None], Callable[[], None]],
|
||||
) -> None:
|
||||
row = _row(parent)
|
||||
_label(row, label)
|
||||
_entry(row, vars_pair[0], 6)
|
||||
_label(row, "on")
|
||||
_entry(row, vars_pair[1], 11)
|
||||
_button(row, "Save", actions[0])
|
||||
_button(row, "Delete that day", actions[1])
|
||||
|
||||
|
||||
def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas:
|
||||
logs = vars_.logs
|
||||
pair = (logs.weight, logs.weight_day)
|
||||
_dated_row(col, "Weight kg", pair, (cb.save_weight, cb.delete_weight))
|
||||
row = _row(col)
|
||||
for value, text in (("weight", "Weight"), ("fat", "Body fat")):
|
||||
tk.Radiobutton(
|
||||
row,
|
||||
text=text,
|
||||
value=value,
|
||||
variable=logs.graph,
|
||||
command=cb.recalc,
|
||||
**_TOGGLE,
|
||||
).pack(side="left")
|
||||
for text, days in GRAPH_RANGES:
|
||||
_button(row, text, lambda d=days: cb.set_range(d))
|
||||
canvas = tk.Canvas(col, width=GRAPH_W, height=GRAPH_H, bg=BG, highlightthickness=0)
|
||||
canvas.pack(anchor="w", pady=(SM, 0))
|
||||
tk.Label(col, textvariable=vars_.status, font=_font(), bg=BG, fg=_MUTED).pack(
|
||||
anchor="w"
|
||||
)
|
||||
_block(col, vars_.blocks["workouts"])
|
||||
return canvas
|
||||
|
||||
|
||||
def _build_right(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
|
||||
for name in ("bmi", "ideal", "bmr"):
|
||||
_block(col, vars_.blocks[name])
|
||||
row = _row(col)
|
||||
_label(row, "Week average target kcal")
|
||||
_entry(row, vars_.plan.target, 6)
|
||||
for name, var in zip(WEEKDAYS, vars_.plan.days, strict=True):
|
||||
_label(row, name)
|
||||
_entry(row, var, 5)
|
||||
_button(row, "Plan week", cb.recalc)
|
||||
_block(col, vars_.blocks["plan"])
|
||||
|
||||
|
||||
def build_body_frame(
|
||||
parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks, *, profile_first: bool
|
||||
) -> BodyWidgets:
|
||||
"""Build the whole tab into ``parent``; return what the controller needs."""
|
||||
frame = tk.Frame(parent, bg=BG)
|
||||
tk.Label(frame, text="Body", font=(*_font(TITLE), "bold"), bg=BG, fg=FG).pack(
|
||||
pady=(MD, SM)
|
||||
)
|
||||
inner = tk.Frame(frame, bg=BG)
|
||||
inner.pack(fill="both", expand=True, padx=MD)
|
||||
if profile_first:
|
||||
_build_profile(inner, vars_, cb)
|
||||
_build_goal(inner, vars_, cb)
|
||||
columns = tk.Frame(inner, bg=BG)
|
||||
columns.pack(anchor="w", pady=(MD, 0))
|
||||
left = tk.Frame(columns, bg=BG)
|
||||
left.pack(side="left", anchor="n", padx=(0, MD))
|
||||
right = tk.Frame(columns, bg=BG)
|
||||
right.pack(side="left", anchor="n")
|
||||
canvas = _build_left(left, vars_, cb)
|
||||
_build_right(right, vars_, cb)
|
||||
if not profile_first:
|
||||
_build_profile(inner, vars_, cb)
|
||||
logs = vars_.logs
|
||||
pair = (logs.body_fat, logs.fat_day)
|
||||
_dated_row(inner, "Body fat %", pair, (cb.save_body_fat, cb.delete_body_fat))
|
||||
_block(inner, vars_.blocks["bodyfat"])
|
||||
return BodyWidgets(frame=frame, canvas=canvas)
|
||||
@ -37,6 +37,7 @@ from diet_guard._calendar_view import (
|
||||
ytd_text,
|
||||
)
|
||||
from diet_guard._daystatus import DayStatus, status_map
|
||||
from diet_guard._gatelock_body import BodyTab
|
||||
from diet_guard._gatelock_calendar_ui import (
|
||||
_DECEMBER,
|
||||
_DEFAULT_BUDGET_KCAL,
|
||||
@ -118,6 +119,7 @@ class _GateCalendar(_GateScheduleEdit):
|
||||
)
|
||||
cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks)
|
||||
notebook.add(cal_widgets.frame, text="History")
|
||||
notebook.add(BodyTab(notebook).container, text="Body")
|
||||
# Ctrl+Tab / Ctrl+PageUp / Ctrl+PageDown and Alt+mnemonic tab switching
|
||||
# all live in toplevel bindings that ttk only installs on request. The
|
||||
# notebook is in the focus ring and Left/Right work once it holds focus,
|
||||
|
||||
110
diet_guard/_manual_workout_parse.py
Normal file
110
diet_guard/_manual_workout_parse.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""Read a screen-locker manual workout's free text for sport and distance.
|
||||
|
||||
A manual entry's sport field is often a generic "other" while the typed text
|
||||
says "Walking at Ai clearing..." -- and a distance, when there is one, lives
|
||||
in the prose ("5 km", "5,2km", "3000 m"). Without this, a walk logged by
|
||||
hand gets only the flat MET estimate even though the user typed its length.
|
||||
|
||||
Duration never comes from here: ``duration_minutes`` is a structured field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
|
||||
from diet_guard._activity_kcal import Session
|
||||
|
||||
__all__ = ["logged_window", "manual_session"]
|
||||
|
||||
_WALK_RE = re.compile(r"\b(walk|walking|walked|hike|hiking|hiked)\b", re.IGNORECASE)
|
||||
_RUN_RE = re.compile(r"\b(run|running|ran|jog|jogging|jogged)\b", re.IGNORECASE)
|
||||
_DISTANCE_RE = re.compile(
|
||||
r"(\d+(?:[.,]\d+)?)\s*(km|kilomet(?:er|re)s?|m|met(?:er|re)s?)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_METRES_PER_KM = 1000.0
|
||||
_SPORT_KINDS = {
|
||||
"running": "run",
|
||||
"walking": "walk",
|
||||
"hiking": "walk",
|
||||
"cycling": "cycle",
|
||||
"biking": "cycle",
|
||||
"strength": "strength",
|
||||
"gym": "strength",
|
||||
}
|
||||
|
||||
|
||||
def _text(item: dict[str, object]) -> str:
|
||||
"""Every free-text field of the entry, joined."""
|
||||
return " ".join(str(v) for v in item.values() if isinstance(v, str))
|
||||
|
||||
|
||||
def _kind(item: dict[str, object], text: str) -> str:
|
||||
"""The sport field if it is specific, else what the prose says."""
|
||||
sport = str(item.get("activity_type") or item.get("sport") or "").lower()
|
||||
if sport in _SPORT_KINDS:
|
||||
return _SPORT_KINDS[sport]
|
||||
if _RUN_RE.search(text):
|
||||
return "run"
|
||||
if _WALK_RE.search(text):
|
||||
return "walk"
|
||||
return "other"
|
||||
|
||||
|
||||
def _typed_km(item: dict[str, object]) -> float | None:
|
||||
"""A structured ``distance_km``, when present and numeric."""
|
||||
raw = item.get("distance_km")
|
||||
try:
|
||||
return float(str(raw)) if raw is not None else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _text_km(text: str) -> float | None:
|
||||
"""The first distance written in ``text``, in km, or None."""
|
||||
match = _DISTANCE_RE.search(text)
|
||||
if match is None:
|
||||
return None
|
||||
value = float(match.group(1).replace(",", "."))
|
||||
in_km = match.group(2).lower().startswith("k")
|
||||
return value if in_km else value / _METRES_PER_KM
|
||||
|
||||
|
||||
def logged_window(stamp: object, minutes: float) -> tuple[str | None, str | None]:
|
||||
"""``(start, end)`` for a workout logged at ``stamp`` as it finished.
|
||||
|
||||
screen-locker stamps a verified workout when it is logged, i.e. at its
|
||||
end, so the start is ``minutes`` earlier. Unparsable stamp -> no window.
|
||||
"""
|
||||
try:
|
||||
end = datetime.fromisoformat(str(stamp)).astimezone()
|
||||
except ValueError:
|
||||
return None, None
|
||||
start = end - timedelta(minutes=minutes)
|
||||
return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _clock_window(day: str, item: dict[str, object]) -> tuple[str | None, str | None]:
|
||||
"""``(start, end)`` from a manual entry's ``HH:MM`` start/end fields."""
|
||||
try:
|
||||
start = datetime.fromisoformat(f"{day}T{item['start_time']}").astimezone()
|
||||
end = datetime.fromisoformat(f"{day}T{item['end_time']}").astimezone()
|
||||
except KeyError, ValueError:
|
||||
return None, None
|
||||
if end < start: # past midnight
|
||||
end += timedelta(days=1)
|
||||
return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def manual_session(
|
||||
day: str, item: dict[str, object], minutes: float, label: str
|
||||
) -> Session:
|
||||
"""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, "screen-locker", start, end)
|
||||
@ -1,34 +1,30 @@
|
||||
"""Carry the phone's morning weigh-in into the budget's stored weight.
|
||||
"""Carry the newest logged weight into the budget's stored weight.
|
||||
|
||||
The wake-alarm phone app ends every morning session with a typed weight,
|
||||
published as ``latest_weight_kg`` on its synced record; ``wake_alarm._weight``
|
||||
reads it on the PC. That weight is what the protein target here is derived
|
||||
from (``_budget_derived.protein_target_g``), so after each successful
|
||||
``sync`` the stored ``w`` is refreshed from it.
|
||||
The Body weight log (:mod:`diet_guard._body_store`) holds every weigh-in --
|
||||
the phone's morning ones, ingested each tick by :mod:`diet_guard._sync_body`,
|
||||
and any typed on either device. Its newest entry is what the protein target
|
||||
here is derived from (``_budget_derived.protein_target_g``), so after each
|
||||
successful ``sync`` the stored ``w`` is refreshed from it.
|
||||
|
||||
Only ``w`` moves. The kcal budget is not recomputed -- biometrics are
|
||||
discarded by design on this side (see ``_budget_biometrics``), so there is
|
||||
nothing to recompute it *from*. ``t`` is refreshed with ``w`` because both
|
||||
ride the same edit timestamp into the per-field last-write-wins merge
|
||||
(``sync_merge._budget``); a new weight under an old ``t`` would lose to
|
||||
every other device on the next merge.
|
||||
|
||||
``wake_alarm`` is an optional neighbour, imported lazily: a machine without
|
||||
it (or with a broken install) syncs exactly as before and simply never
|
||||
refreshes the weight.
|
||||
Only ``w`` moves. The kcal budget is never recomputed -- the Body tab's
|
||||
calorie table is informational, and the budget stays the number the user
|
||||
edits. ``t`` is refreshed with ``w`` because both ride the same edit
|
||||
timestamp into the per-field last-write-wins merge (``sync_merge._budget``);
|
||||
a new weight under an old ``t`` would lose to every other device on the next
|
||||
merge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._body_store import newest_weight
|
||||
from diet_guard._budget import _now_local, read_raw_record, write_raw_record
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
__all__ = ["refresh_weight_from_phone", "update_weight"]
|
||||
__all__ = ["refresh_weight_from_log", "update_weight"]
|
||||
|
||||
# Below this the scale and the record agree; a rewrite would only churn ``t``.
|
||||
_SAME_WEIGHT_KG = 0.05
|
||||
@ -55,14 +51,12 @@ def _budget_edit_day(record: dict[str, object]) -> str:
|
||||
return str(record.get("t", ""))[:10]
|
||||
|
||||
|
||||
def refresh_weight_from_phone(emit: Callable[[str], None]) -> None:
|
||||
"""Apply the phone's newest weigh-in to the stored weight, if it is newer.
|
||||
def refresh_weight_from_log(emit: Callable[[str], None]) -> None:
|
||||
"""Apply the weight log's newest weigh-in to the stored weight, if newer.
|
||||
|
||||
Skipped, silently, when: ``wake_alarm`` is not installed; nothing has
|
||||
been published (or the backend is unreadable -- ``latest_weight`` never
|
||||
raises); no budget exists; the weigh-in is not from a *later* day than
|
||||
the budget's last edit; or it matches the stored weight within
|
||||
:data:`_SAME_WEIGHT_KG`.
|
||||
Skipped, silently, when: the log is empty; no budget exists; the
|
||||
weigh-in is not from a *later* day than the budget's last edit; or it
|
||||
matches the stored weight within :data:`_SAME_WEIGHT_KG`.
|
||||
|
||||
The day comparison is strict. A weigh-in happens in the morning and a
|
||||
manual budget edit at any time of day, and ``t`` carries only the day's
|
||||
@ -74,24 +68,21 @@ def refresh_weight_from_phone(emit: Callable[[str], None]) -> None:
|
||||
Args:
|
||||
emit: One-line output sink, given a line only when a write happened.
|
||||
"""
|
||||
try:
|
||||
weight_module = import_module("wake_alarm._weight")
|
||||
except ImportError:
|
||||
return
|
||||
weight = weight_module.latest_weight()
|
||||
if weight is None:
|
||||
newest = newest_weight()
|
||||
if newest is None:
|
||||
return
|
||||
day, kg = newest
|
||||
record = read_raw_record()
|
||||
if record is None:
|
||||
return
|
||||
if weight.date <= _budget_edit_day(record):
|
||||
if day <= _budget_edit_day(record):
|
||||
return
|
||||
stored = record.get("w")
|
||||
if (
|
||||
isinstance(stored, (int, float))
|
||||
and not isinstance(stored, bool)
|
||||
and abs(float(stored) - weight.kg) < _SAME_WEIGHT_KG
|
||||
and abs(float(stored) - kg) < _SAME_WEIGHT_KG
|
||||
):
|
||||
return
|
||||
update_weight(weight.kg)
|
||||
emit(f"weight: {weight.kg} kg from the phone ({weight.date}).")
|
||||
update_weight(kg)
|
||||
emit(f"weight: {kg} kg from the weight log ({day}).")
|
||||
|
||||
@ -37,13 +37,14 @@ from diet_guard._constants import (
|
||||
)
|
||||
from diet_guard._device import device_identity
|
||||
from diet_guard._foodbank_rebuild import rebuild_food_bank
|
||||
from diet_guard._phone_weight import refresh_weight_from_phone
|
||||
from diet_guard._phone_weight import refresh_weight_from_log
|
||||
from diet_guard._state_sync import (
|
||||
read_raw_log,
|
||||
resign_entry,
|
||||
write_raw_log,
|
||||
)
|
||||
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_errors import SyncError
|
||||
from diet_guard._sync_kuchnia import _sync_kuchnia_credential
|
||||
@ -182,6 +183,7 @@ def run_sync() -> DayLog:
|
||||
_sync_food_bank(client, device_ids)
|
||||
_sync_manual_bank(client, device_ids)
|
||||
_sync_kuchnia_credential(client, device_ids)
|
||||
sync_body(client, device_ids)
|
||||
|
||||
push_log = daylog_to_log(resigned)
|
||||
push_json = json.dumps(
|
||||
@ -207,7 +209,7 @@ def run_sync() -> DayLog:
|
||||
# the merged winner, so the refreshed ``t`` re-asserts nothing stale.
|
||||
# Here rather than in the ``sync`` subcommand because on the PC a full
|
||||
# tick runs from ``publish_after_log``, never from that command.
|
||||
refresh_weight_from_phone(lambda line: _logger.info("%s", line))
|
||||
refresh_weight_from_log(lambda line: _logger.info("%s", line))
|
||||
return resigned
|
||||
|
||||
|
||||
|
||||
84
diet_guard/_sync_body.py
Normal file
84
diet_guard/_sync_body.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Pull/merge/push for the Body document, plus the two things that feed it.
|
||||
|
||||
Runs inside the full sync tick (:func:`diet_guard._sync.run_sync`), after
|
||||
the budget. Order matters:
|
||||
|
||||
1. **Ingest the phone's weigh-ins** from wake-alarm's record
|
||||
(:mod:`diet_guard._body_ingest`), so a morning weigh-in lands in the log
|
||||
even when the phone's diet-guard app never opened.
|
||||
2. **Publish workouts** (:mod:`diet_guard._activity_sources`) -- PC only, and
|
||||
only when a source exists here, so a machine without RunnerUp or
|
||||
screen-locker can never publish an empty list over the real one.
|
||||
3. **Merge** with every peer's ``body.json`` (per-field LWW), persist, push.
|
||||
|
||||
Body is a Firebase-era document, so peers are read from the primary only
|
||||
(:func:`diet_guard._sync_banks._primary_only_text`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import merge_logs
|
||||
|
||||
from diet_guard._activity_sources import collect_sessions, sources_available
|
||||
from diet_guard._body_activity_store import set_activity
|
||||
from diet_guard._body_ingest import fetch_alarm_logs, weight_candidates
|
||||
from diet_guard._body_store import apply_weight_candidates, read_body, write_body
|
||||
from diet_guard._device import device_identity
|
||||
from diet_guard._state import now_local
|
||||
from diet_guard._sync_banks import _primary_only_text
|
||||
from diet_guard._sync_paths import _device_body_path
|
||||
from diet_guard.sync_merge._body import (
|
||||
body_to_log,
|
||||
encode_body_for_push,
|
||||
log_to_body,
|
||||
parse_remote_body,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from crdt_sync import RemoteStore
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def publish_activity() -> None:
|
||||
"""Refresh the published workouts from this machine's sources, if any."""
|
||||
if not sources_available():
|
||||
return
|
||||
if set_activity(collect_sessions(now_local().date())):
|
||||
_logger.info("activity: published updated workout sessions")
|
||||
|
||||
|
||||
def sync_body(client: RemoteStore, device_ids: Sequence[str]) -> None:
|
||||
"""Ingest weigh-ins, publish workouts, then merge and push ``body.json``."""
|
||||
applied = apply_weight_candidates(weight_candidates(fetch_alarm_logs(client)))
|
||||
if applied:
|
||||
_logger.info("body: %d weigh-in(s) from the phone", applied)
|
||||
publish_activity()
|
||||
identity = device_identity()
|
||||
merged = body_to_log(read_body())
|
||||
for device_id in device_ids:
|
||||
if identity.is_own(device_id):
|
||||
continue
|
||||
text = _primary_only_text(client, _device_body_path(device_id))
|
||||
if text is None:
|
||||
continue
|
||||
try:
|
||||
merged = merge_logs(merged, parse_remote_body(text))
|
||||
except TypeError, KeyError, ValueError, json.JSONDecodeError:
|
||||
_logger.warning(
|
||||
"Unparsable body document from device %r, skipping", device_id
|
||||
)
|
||||
if not merged:
|
||||
return
|
||||
write_body(log_to_body(merged))
|
||||
client.put_file_text(
|
||||
_device_body_path(identity.device_id),
|
||||
encode_body_for_push(merged),
|
||||
message="diet_guard sync: body",
|
||||
)
|
||||
@ -47,3 +47,8 @@ def _device_kuchnia_path(device_id: str) -> str:
|
||||
be readable by anything that reads the budget.
|
||||
"""
|
||||
return f"{_DEVICES_DIR}/{device_id}/kuchnia.json"
|
||||
|
||||
|
||||
def _device_body_path(device_id: str) -> str:
|
||||
"""Remote path of ``device_id``'s Body document (profile, weights, workouts)."""
|
||||
return f"{_DEVICES_DIR}/{device_id}/body.json"
|
||||
|
||||
125
diet_guard/_week_plan.py
Normal file
125
diet_guard/_week_plan.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Weekly-average planner: what to eat on the rest of the week's days.
|
||||
|
||||
"I want to average N kcal this week, and on Monday I ate 2500 -- what do I
|
||||
eat on the other days?" The week is Monday-Sunday around ``today``. A day is
|
||||
*known* when the user typed a number for it, or when it is already over
|
||||
(before ``today``) and has meals in the log. Every other day -- today
|
||||
included, since it is still in progress -- shares what is left evenly::
|
||||
|
||||
per_day = (target x 7 - sum(known)) / remaining_days
|
||||
|
||||
Informational only, like the calorie table: it never touches the budget.
|
||||
Pure over its arguments; mirrored in ``app/lib/services/week_plan.dart`` and
|
||||
gated by the shared ``tests/fixtures/body_calc.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
|
||||
from diet_guard._body_calc import half_up
|
||||
from diet_guard._body_energy import MIN_TARGET_KCAL
|
||||
|
||||
__all__ = ["WEEKDAYS", "DayPlan", "WeekPlan", "plan_week", "week_start"]
|
||||
|
||||
WEEKDAYS: tuple[str, ...] = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayPlan:
|
||||
"""One day of the plan.
|
||||
|
||||
Attributes:
|
||||
day: ``YYYY-MM-DD``.
|
||||
weekday: ``Mon`` .. ``Sun``.
|
||||
kcal: What was eaten (known) or should be eaten (planned).
|
||||
source: ``typed``, ``logged``, ``plan``, or ``unlogged`` -- a day
|
||||
already over with nothing in the log, planned like a remaining
|
||||
day (an unlogged day is not a zero-kcal day) so the screens can
|
||||
ask for the real number.
|
||||
"""
|
||||
|
||||
day: str
|
||||
weekday: str
|
||||
kcal: int
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeekPlan:
|
||||
"""The whole week.
|
||||
|
||||
Attributes:
|
||||
days: Monday .. Sunday.
|
||||
target_avg: The weekly average aimed for.
|
||||
per_remaining: kcal for each ``plan`` day, or None when none remain.
|
||||
week_avg: The average the plan produces (equals ``target_avg`` up to
|
||||
rounding when anything remains; the achieved average otherwise).
|
||||
"""
|
||||
|
||||
days: tuple[DayPlan, ...]
|
||||
target_avg: int
|
||||
per_remaining: int | None
|
||||
week_avg: float
|
||||
|
||||
@property
|
||||
def below_floor(self) -> bool:
|
||||
"""Whether the remaining days would have to go under 1200 kcal."""
|
||||
return self.per_remaining is not None and self.per_remaining < MIN_TARGET_KCAL
|
||||
|
||||
|
||||
def _open_source(monday: date, index: int, today: date) -> str:
|
||||
"""``unlogged`` for a past day without data, ``plan`` for today onward."""
|
||||
return "unlogged" if monday + timedelta(days=index) < today else "plan"
|
||||
|
||||
|
||||
def week_start(today: date) -> date:
|
||||
"""The Monday of ``today``'s week."""
|
||||
return today - timedelta(days=today.weekday())
|
||||
|
||||
|
||||
def plan_week(
|
||||
today: date,
|
||||
target_avg: int,
|
||||
logged: dict[str, float],
|
||||
typed: dict[int, int],
|
||||
) -> WeekPlan:
|
||||
"""Plan the rest of ``today``'s week.
|
||||
|
||||
Args:
|
||||
today: The current local date.
|
||||
target_avg: Desired mean kcal/day over the seven days.
|
||||
logged: ``YYYY-MM-DD`` -> kcal eaten, from the food log. Only days
|
||||
before ``today`` are used.
|
||||
typed: Weekday index (0 = Monday) -> kcal the user typed; wins over
|
||||
the log, and may name any day (a planned feast on Saturday too).
|
||||
"""
|
||||
monday = week_start(today)
|
||||
known: dict[int, tuple[int, str]] = {}
|
||||
for index in range(7):
|
||||
day = monday + timedelta(days=index)
|
||||
if index in typed:
|
||||
known[index] = (typed[index], "typed")
|
||||
elif day < today and day.isoformat() in logged:
|
||||
known[index] = (int(half_up(logged[day.isoformat()])), "logged")
|
||||
remaining = 7 - len(known)
|
||||
eaten = sum(kcal for kcal, _ in known.values())
|
||||
per = int(half_up((target_avg * 7 - eaten) / remaining)) if remaining else None
|
||||
days = tuple(
|
||||
DayPlan(
|
||||
day=(monday + timedelta(days=index)).isoformat(),
|
||||
weekday=WEEKDAYS[index],
|
||||
kcal=known[index][0] if index in known else int(per or 0),
|
||||
source=known[index][1]
|
||||
if index in known
|
||||
else _open_source(monday, index, today),
|
||||
)
|
||||
for index in range(7)
|
||||
)
|
||||
return WeekPlan(
|
||||
days=days,
|
||||
target_avg=target_avg,
|
||||
per_remaining=per,
|
||||
week_avg=sum(d.kcal for d in days) / 7,
|
||||
)
|
||||
150
diet_guard/sync_merge/_body.py
Normal file
150
diet_guard/sync_merge/_body.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Body document <-> ``crdt_sync.Log`` adapters (profile, weights, workouts).
|
||||
|
||||
Wire format in ``docs/DOCS-body.md``: record ``profile`` with one field per
|
||||
profile value; one record per day for each of weights (``w:<YYYY-MM-DD>``),
|
||||
body fat (``bf:...``) and phone-written steps (``st:...``); and one
|
||||
``activity`` record carrying the PC's published sessions. Every field's Hlc
|
||||
is derived from its own ``t`` edit stamp, so an unchanged document
|
||||
re-derives identical clocks and re-syncing it is a no-op -- the same
|
||||
determinism the budget adapter relies on.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/services/sync_merge_body.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc, Record
|
||||
|
||||
from diet_guard._device import device_id
|
||||
from diet_guard.sync_merge._clock import _wall_time_ms
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crdt_sync import Log
|
||||
|
||||
PROFILE_RECORD_ID = "profile"
|
||||
ACTIVITY_RECORD_ID = "activity"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatedLog:
|
||||
"""One per-day section: its record prefix, field name and value keys."""
|
||||
|
||||
section: str
|
||||
prefix: str
|
||||
field: str
|
||||
keys: tuple[str, ...]
|
||||
|
||||
|
||||
# Weights, body fat and (phone-written) steps all sync the same way: one
|
||||
# record per date, one field holding a small map, LWW by its edit stamp.
|
||||
DATED_LOGS: tuple[DatedLog, ...] = (
|
||||
DatedLog("weights", "w:", "kg", ("kg", "src")),
|
||||
DatedLog("bodyfat", "bf:", "pct", ("pct",)),
|
||||
DatedLog("steps", "st:", "n", ("n",)),
|
||||
)
|
||||
_DEFAULTS: dict[str, object] = {"src": ""}
|
||||
|
||||
|
||||
def _hlc(stamp: object) -> Hlc:
|
||||
"""The deterministic clock for an edit stamp (epoch when unparsable)."""
|
||||
return Hlc.new_tick(device_id(), wall_time_ms=_wall_time_ms(str(stamp)))
|
||||
|
||||
|
||||
def _stamp(hlc: Hlc) -> str:
|
||||
"""The local ISO edit stamp a merged field's clock stands for."""
|
||||
moment = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
|
||||
return moment.astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def body_to_log(doc: dict[str, dict[str, object]]) -> Log:
|
||||
"""Convert the local Body document (see ``_body_store``) into a Log."""
|
||||
log: Log = {}
|
||||
profile_fields = {
|
||||
name: (cell.get("v"), _hlc(cell.get("t")))
|
||||
for name, cell in doc["profile"].items()
|
||||
if isinstance(cell, dict)
|
||||
}
|
||||
if profile_fields:
|
||||
log[PROFILE_RECORD_ID] = Record(id=PROFILE_RECORD_ID, fields=profile_fields)
|
||||
for dated in DATED_LOGS:
|
||||
for day, cell in doc[dated.section].items():
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
record_id = f"{dated.prefix}{day}"
|
||||
value = {key: cell.get(key, _DEFAULTS.get(key)) for key in dated.keys}
|
||||
hlc = _hlc(cell.get("t"))
|
||||
log[record_id] = Record(id=record_id, fields={dated.field: (value, hlc)})
|
||||
sessions = doc["activity"].get("sessions")
|
||||
if isinstance(sessions, list):
|
||||
hlc = _hlc(doc["activity"].get("t"))
|
||||
log[ACTIVITY_RECORD_ID] = Record(
|
||||
id=ACTIVITY_RECORD_ID, fields={"sessions": (sessions, hlc)}
|
||||
)
|
||||
return log
|
||||
|
||||
|
||||
def _dated_cell(dated: DatedLog, record: Record) -> dict[str, object] | None:
|
||||
"""One dated record back as a local cell, or None when malformed."""
|
||||
if dated.field not in record.fields:
|
||||
return None
|
||||
value, hlc = record.fields[dated.field]
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
cell = {key: value.get(key, _DEFAULTS.get(key)) for key in dated.keys}
|
||||
return {**cell, "t": _stamp(hlc)}
|
||||
|
||||
|
||||
def log_to_body(log: Log) -> dict[str, dict[str, object]]:
|
||||
"""Convert a merged Log back into the local document shape."""
|
||||
doc: dict[str, dict[str, object]] = {
|
||||
"profile": {},
|
||||
"activity": {},
|
||||
**{dated.section: {} for dated in DATED_LOGS},
|
||||
}
|
||||
profile = log.get(PROFILE_RECORD_ID)
|
||||
if profile is not None:
|
||||
doc["profile"] = {
|
||||
name: {"v": value, "t": _stamp(hlc)}
|
||||
for name, (value, hlc) in profile.fields.items()
|
||||
}
|
||||
for record_id, record in log.items():
|
||||
for dated in DATED_LOGS:
|
||||
if record_id.startswith(dated.prefix):
|
||||
cell = _dated_cell(dated, record)
|
||||
if cell is not None:
|
||||
doc[dated.section][record_id[len(dated.prefix) :]] = cell
|
||||
activity = log.get(ACTIVITY_RECORD_ID)
|
||||
if activity is not None and "sessions" in activity.fields:
|
||||
sessions, hlc = activity.fields["sessions"]
|
||||
if isinstance(sessions, list):
|
||||
doc["activity"] = {"sessions": sessions, "t": _stamp(hlc)}
|
||||
return doc
|
||||
|
||||
|
||||
def parse_remote_body(text: str) -> Log:
|
||||
"""Parse one device's pushed ``body.json`` into a Log.
|
||||
|
||||
Raises:
|
||||
TypeError: The top level is not a JSON object.
|
||||
KeyError: Via ``Record.from_dict`` on a record missing a key.
|
||||
ValueError: Invalid JSON, or a malformed clock string.
|
||||
"""
|
||||
raw = json.loads(text)
|
||||
if not isinstance(raw, dict):
|
||||
msg = f"top-level body payload is not a JSON object: {raw!r}"
|
||||
raise TypeError(msg)
|
||||
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}
|
||||
|
||||
|
||||
def encode_body_for_push(log: Log) -> str:
|
||||
"""Serialise a merged Log for push."""
|
||||
return json.dumps(
|
||||
{record_id: record.to_dict() for record_id, record in log.items()},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
@ -23,6 +23,8 @@ import pytest
|
||||
|
||||
from diet_guard import (
|
||||
_gatelock,
|
||||
_gatelock_body_vars,
|
||||
_gatelock_body_widgets,
|
||||
_gatelock_budgetedit,
|
||||
_gatelock_calendar,
|
||||
_gatelock_calendar_schedule,
|
||||
@ -157,6 +159,10 @@ _GATE_TK_MODULES = (
|
||||
# separately in fake_tk(). Listing it here fails with "does not have the
|
||||
# attribute 'tk'".
|
||||
_gatelock_calendar_widgets,
|
||||
# The Body tab builds its own widgets and vars (the controller only
|
||||
# wires them).
|
||||
_gatelock_body_vars,
|
||||
_gatelock_body_widgets,
|
||||
# The History tab's meal-schedule row builds its own tk widgets.
|
||||
_gatelock_calendar_schedule,
|
||||
# Builds the History tab's StringVars (make_calendar_vars); without the
|
||||
|
||||
152
diet_guard/tests/_state_redirects.py
Normal file
152
diet_guard/tests/_state_redirects.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""The list of state/network redirects ``conftest._isolate_state`` applies.
|
||||
|
||||
Split out of ``conftest.py`` to keep it under the 250-line cap. Every on-disk
|
||||
state path and every network entry point a test could reach is patched here;
|
||||
a new one missing from this list writes to (or reads from) the live machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from unittest.mock import _patch
|
||||
|
||||
|
||||
def state_redirects(tmp_path: Path) -> list[_patch[object]]:
|
||||
"""Every patch that points diet_guard state and network at ``tmp_path``."""
|
||||
return [
|
||||
# The shared free-day pool lives under ~/.local/share, outside every
|
||||
# diet_guard path below. Without this a real free day on the
|
||||
# developer's machine would make every "the gate is due" test here
|
||||
# fail, for a reason that looks nothing like the cause.
|
||||
patch(
|
||||
"freedays._api.resolve_paths",
|
||||
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
|
||||
),
|
||||
patch(
|
||||
"diet_guard._budget.BUDGET_FILE",
|
||||
tmp_path / ".budget",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._budget_history.BUDGET_HISTORY_FILE",
|
||||
tmp_path / ".budget_history",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._meal_schedule_store.MEAL_SCHEDULE_FILE",
|
||||
tmp_path / ".meal_schedule",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._state.FOOD_LOG_FILE",
|
||||
tmp_path / "food_log.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._foodbank.FOOD_BANK_FILE",
|
||||
tmp_path / "food_bank.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._foodbank_manual.MANUAL_BANK_FILE",
|
||||
tmp_path / "food_bank_manual.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._gatelock_lockfile.GATE_LOCK_FILE",
|
||||
tmp_path / ".gate.lock",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._sync_client.SYNC_TOKEN_FILE",
|
||||
tmp_path / "sync_token",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._sync.SYNC_STATE_FILE",
|
||||
tmp_path / "sync_state.json",
|
||||
),
|
||||
# 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.
|
||||
patch(
|
||||
"diet_guard._device.SYNC_DEVICE_ID_FILE",
|
||||
tmp_path / ".device_id",
|
||||
),
|
||||
# `run_sync` reads this to decide whether to build a Firebase-primary
|
||||
# mirror. On a developer machine the real file exists, so without this
|
||||
# every sync test would sign in and push to the live database.
|
||||
patch(
|
||||
"diet_guard._sync_client.CONFIG_FILE",
|
||||
tmp_path / "nonexistent-firebase.json",
|
||||
),
|
||||
# Every full tick ends by copying the weight log's newest weigh-in
|
||||
# into the budget's `w`. `test_sync_budget` asserts the call; every
|
||||
# other sync test keeps the budget record untouched by it.
|
||||
patch("diet_guard._sync.refresh_weight_from_log", return_value=None),
|
||||
# Logging a meal now publishes immediately (`_sync_events`), so every
|
||||
# test that logs one would otherwise open a real connection through a
|
||||
# path that has nothing to do with what it is asserting. Patched at the
|
||||
# *call sites* rather than on `_sync_events`, so a test that imports
|
||||
# `publish_after_log` directly still exercises the real helper.
|
||||
patch("diet_guard._cli_log.publish_after_log_detached", return_value=None),
|
||||
patch("diet_guard._cli_gate.publish_after_log", return_value=None),
|
||||
patch("diet_guard._mcp.publish_after_log", return_value=None),
|
||||
patch("diet_guard._cli_body.publish_after_log", return_value=None),
|
||||
# The gate's pre-lock refresh is a *second*, independent network
|
||||
# entry point (narrow peer-log pull, not the full tick). Without
|
||||
# this every test that reaches `_should_lock` hits the real remote.
|
||||
patch("diet_guard._cli_gate.pull_peer_logs", return_value=None),
|
||||
# The catering credentials and its cached session cookie. Redirected
|
||||
# for the same reason as `sync_token`: `_test_guard` raises on any
|
||||
# write under the real ~/.config/diet_guard, and a test that logs in
|
||||
# would otherwise clobber the live session. Deliberately NOT added to
|
||||
# `test_state_redirect._REDIRECTED_CONSTANTS`, matching
|
||||
# `SYNC_TOKEN_FILE` -- that check enforces a single naming module,
|
||||
# which would forbid the CLI naming the path in its own setup text.
|
||||
patch(
|
||||
"diet_guard._kuchnia_config.KUCHNIA_CREDENTIALS_FILE",
|
||||
tmp_path / "kuchnia_credentials",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._kuchnia_config.KUCHNIA_SESSION_FILE",
|
||||
tmp_path / "kuchnia_session.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._kuchnia_credential_store.KUCHNIA_SYNCED_CREDENTIAL_FILE",
|
||||
tmp_path / "kuchnia_synced_credential.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._kuchnia_config.KUCHNIA_LAST_IMPORT_FILE",
|
||||
tmp_path / "kuchnia_last_import",
|
||||
),
|
||||
# The catering fetch is a third network entry point. Patched at each
|
||||
# call site, so a test importing `refresh_delivery` directly still
|
||||
# exercises the real helper.
|
||||
patch("diet_guard._cli_kuchnia.refresh_delivery", return_value=([], None)),
|
||||
# Logging a meal now warms the catering bank on a background
|
||||
# thread. Without this every meal-logging test in the suite reaches
|
||||
# the live panel.
|
||||
patch(
|
||||
"diet_guard._cli_log.refresh_delivery_once",
|
||||
return_value=([], None),
|
||||
),
|
||||
# Logging a meal now warms the catering bank on a background
|
||||
# thread. Without this every meal-logging test in the suite reaches
|
||||
# the live panel.
|
||||
patch(
|
||||
"diet_guard._gatelock_delivery.refresh_delivery",
|
||||
return_value=([], None),
|
||||
),
|
||||
# The Body tab's profile, weight log and published workouts.
|
||||
patch("diet_guard._body_store.BODY_FILE", tmp_path / "body.json"),
|
||||
# Workout sources live outside diet_guard entirely: RunnerUp's WebDAV
|
||||
# drop and screen-locker's signed log. Reads, not writes -- but a test
|
||||
# reading the real ones is non-deterministic, so both point nowhere.
|
||||
patch(
|
||||
"diet_guard._activity_sources.RUNNERUP_DIRS",
|
||||
(tmp_path / "runnerup",),
|
||||
),
|
||||
patch(
|
||||
"diet_guard._activity_sources.screen_locker_log_file",
|
||||
return_value=None,
|
||||
),
|
||||
]
|
||||
@ -164,6 +164,7 @@ _FAKE_TK = SimpleNamespace(
|
||||
Button=FakeWidget,
|
||||
OptionMenu=FakeWidget,
|
||||
Radiobutton=FakeRadiobutton,
|
||||
Checkbutton=FakeWidget,
|
||||
Entry=FakeEntry,
|
||||
Text=FakeText,
|
||||
Listbox=FakeListbox,
|
||||
|
||||
@ -50,6 +50,29 @@ class FakeCanvas(FakeWidget):
|
||||
def itemconfigure(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
# Drawing, for the Body tab's weight graph: recorded as
|
||||
# ``(kind, args, kwargs)`` so a test can assert what was drawn.
|
||||
def delete(self, *_args: object) -> None:
|
||||
self.drawn: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
|
||||
|
||||
def _draw(
|
||||
self, kind: str, args: tuple[object, ...], kwargs: dict[str, object]
|
||||
) -> int:
|
||||
self.drawn.append((kind, args, kwargs))
|
||||
return len(self.drawn)
|
||||
|
||||
def create_line(self, *args: object, **kwargs: object) -> int:
|
||||
return self._draw("line", args, kwargs)
|
||||
|
||||
def create_oval(self, *args: object, **kwargs: object) -> int:
|
||||
return self._draw("oval", args, kwargs)
|
||||
|
||||
def create_rectangle(self, *args: object, **kwargs: object) -> int:
|
||||
return self._draw("rectangle", args, kwargs)
|
||||
|
||||
def create_text(self, *args: object, **kwargs: object) -> int:
|
||||
return self._draw("text", args, kwargs)
|
||||
|
||||
def coords(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@ -22,9 +22,7 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
import pytest
|
||||
|
||||
from diet_guard._estimator import Nutrition
|
||||
@ -44,6 +42,7 @@ from diet_guard.tests._gate_fixtures import (
|
||||
fake_tk,
|
||||
gate,
|
||||
)
|
||||
from diet_guard.tests._state_redirects import state_redirects
|
||||
from diet_guard.tests._tk_fakes import (
|
||||
_FAKE_TK,
|
||||
_FAKE_TTK,
|
||||
@ -103,124 +102,7 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
|
||||
tuple: the tuple form is a statically nested block per entry, and at
|
||||
~20 redirects CPython refuses to compile it.
|
||||
"""
|
||||
redirects = [
|
||||
# The shared free-day pool lives under ~/.local/share, outside every
|
||||
# diet_guard path below. Without this a real free day on the
|
||||
# developer's machine would make every "the gate is due" test here
|
||||
# fail, for a reason that looks nothing like the cause.
|
||||
patch(
|
||||
"freedays._api.resolve_paths",
|
||||
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
|
||||
),
|
||||
patch(
|
||||
"diet_guard._budget.BUDGET_FILE",
|
||||
tmp_path / ".budget",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._budget_history.BUDGET_HISTORY_FILE",
|
||||
tmp_path / ".budget_history",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._meal_schedule_store.MEAL_SCHEDULE_FILE",
|
||||
tmp_path / ".meal_schedule",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._state.FOOD_LOG_FILE",
|
||||
tmp_path / "food_log.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._foodbank.FOOD_BANK_FILE",
|
||||
tmp_path / "food_bank.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._foodbank_manual.MANUAL_BANK_FILE",
|
||||
tmp_path / "food_bank_manual.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._gatelock_lockfile.GATE_LOCK_FILE",
|
||||
tmp_path / ".gate.lock",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._sync_client.SYNC_TOKEN_FILE",
|
||||
tmp_path / "sync_token",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._sync.SYNC_STATE_FILE",
|
||||
tmp_path / "sync_state.json",
|
||||
),
|
||||
# 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.
|
||||
patch(
|
||||
"diet_guard._device.SYNC_DEVICE_ID_FILE",
|
||||
tmp_path / ".device_id",
|
||||
),
|
||||
# `run_sync` reads this to decide whether to build a Firebase-primary
|
||||
# mirror. On a developer machine the real file exists, so without this
|
||||
# every sync test would sign in and push to the live database.
|
||||
patch(
|
||||
"diet_guard._sync_client.CONFIG_FILE",
|
||||
tmp_path / "nonexistent-firebase.json",
|
||||
),
|
||||
# Every full tick ends by reading the phone's weigh-in through
|
||||
# `wake_alarm._weight`, which is installed here and reads the live
|
||||
# Firebase database. `test_sync_budget` asserts the call; nothing
|
||||
# else may pay for it.
|
||||
patch("diet_guard._sync.refresh_weight_from_phone", return_value=None),
|
||||
# Logging a meal now publishes immediately (`_sync_events`), so every
|
||||
# test that logs one would otherwise open a real connection through a
|
||||
# path that has nothing to do with what it is asserting. Patched at the
|
||||
# *call sites* rather than on `_sync_events`, so a test that imports
|
||||
# `publish_after_log` directly still exercises the real helper.
|
||||
patch("diet_guard._cli_log.publish_after_log_detached", return_value=None),
|
||||
patch("diet_guard._cli_gate.publish_after_log", return_value=None),
|
||||
patch("diet_guard._mcp.publish_after_log", return_value=None),
|
||||
# The gate's pre-lock refresh is a *second*, independent network
|
||||
# entry point (narrow peer-log pull, not the full tick). Without
|
||||
# this every test that reaches `_should_lock` hits the real remote.
|
||||
patch("diet_guard._cli_gate.pull_peer_logs", return_value=None),
|
||||
# The catering credentials and its cached session cookie. Redirected
|
||||
# for the same reason as `sync_token`: `_test_guard` raises on any
|
||||
# write under the real ~/.config/diet_guard, and a test that logs in
|
||||
# would otherwise clobber the live session. Deliberately NOT added to
|
||||
# `test_state_redirect._REDIRECTED_CONSTANTS`, matching
|
||||
# `SYNC_TOKEN_FILE` -- that check enforces a single naming module,
|
||||
# which would forbid the CLI naming the path in its own setup text.
|
||||
patch(
|
||||
"diet_guard._kuchnia_config.KUCHNIA_CREDENTIALS_FILE",
|
||||
tmp_path / "kuchnia_credentials",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._kuchnia_config.KUCHNIA_SESSION_FILE",
|
||||
tmp_path / "kuchnia_session.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._kuchnia_credential_store.KUCHNIA_SYNCED_CREDENTIAL_FILE",
|
||||
tmp_path / "kuchnia_synced_credential.json",
|
||||
),
|
||||
patch(
|
||||
"diet_guard._kuchnia_config.KUCHNIA_LAST_IMPORT_FILE",
|
||||
tmp_path / "kuchnia_last_import",
|
||||
),
|
||||
# The catering fetch is a third network entry point. Patched at each
|
||||
# call site, so a test importing `refresh_delivery` directly still
|
||||
# exercises the real helper.
|
||||
patch("diet_guard._cli_kuchnia.refresh_delivery", return_value=([], None)),
|
||||
# Logging a meal now warms the catering bank on a background
|
||||
# thread. Without this every meal-logging test in the suite reaches
|
||||
# the live panel.
|
||||
patch(
|
||||
"diet_guard._cli_log.refresh_delivery_once",
|
||||
return_value=([], None),
|
||||
),
|
||||
# Logging a meal now warms the catering bank on a background
|
||||
# thread. Without this every meal-logging test in the suite reaches
|
||||
# the live panel.
|
||||
patch(
|
||||
"diet_guard._gatelock_delivery.refresh_delivery",
|
||||
return_value=([], None),
|
||||
),
|
||||
]
|
||||
redirects = state_redirects(tmp_path)
|
||||
with ExitStack() as stack:
|
||||
for redirect in redirects:
|
||||
stack.enter_context(redirect)
|
||||
|
||||
211
diet_guard/tests/test_activity_sources.py
Normal file
211
diet_guard/tests/test_activity_sources.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""Workouts from RunnerUp TCX files and screen-locker's log, deduplicated.
|
||||
|
||||
conftest points ``RUNNERUP_DIRS`` at an empty tmp dir and stubs
|
||||
``screen_locker_log_file``; each test here supplies its own sources.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from diet_guard import _activity_sources
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# Captured at import, before conftest's autouse patch replaces the attribute.
|
||||
_REAL_LOG_FILE = _activity_sources.screen_locker_log_file
|
||||
# screen-locker is not installed on CI (nor in the dev venv): stand in for its
|
||||
# ``_log_io`` with the one behaviour used -- read the day-keyed JSON.
|
||||
_FAKE_LOG_IO = SimpleNamespace(
|
||||
load_workout_log=lambda path: json.loads(path.read_text(encoding="utf-8"))
|
||||
)
|
||||
|
||||
_TCX = """<?xml version="1.0"?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
|
||||
<Activities><Activity Sport="{sport}"><Id>{ident}</Id>
|
||||
<Lap><TotalTimeSeconds>1800</TotalTimeSeconds><DistanceMeters>{m}</DistanceMeters></Lap>
|
||||
<Lap><TotalTimeSeconds>600</TotalTimeSeconds><DistanceMeters>0</DistanceMeters></Lap>
|
||||
</Activity></Activities>
|
||||
</TrainingCenterDatabase>"""
|
||||
|
||||
|
||||
def _tcx(
|
||||
folder: Path, name: str, ident: str, sport: str = "Running", m: int = 5000
|
||||
) -> None:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / name).write_text(
|
||||
_TCX.format(sport=sport, ident=ident, m=m), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _entry(
|
||||
data: dict[str, object], stamp: str = "2026-09-20T17:00:00+00:00"
|
||||
) -> dict[str, object]:
|
||||
return {"timestamp": stamp, "workout_data": data}
|
||||
|
||||
|
||||
class TestRunnerUp:
|
||||
def test_parses_dedupes_and_windows(self, tmp_path: Path) -> None:
|
||||
inbox, done = tmp_path / "in", tmp_path / "in/processed"
|
||||
_tcx(inbox, "a.tcx", "2026-09-20T17:00:00Z")
|
||||
_tcx(done, "a.tcx", "2026-09-20T17:00:00Z") # same activity, moved
|
||||
_tcx(done, "old.tcx", "2026-08-01T17:00:00Z")
|
||||
_tcx(done, "walk.tcx", "2026-09-21T08:00:00Z", sport="Walking", m=0)
|
||||
(done / "broken.tcx").write_text("<nope", encoding="utf-8")
|
||||
(done / "empty.tcx").write_text("<TrainingCenterDatabase/>", encoding="utf-8")
|
||||
with patch.object(
|
||||
_activity_sources, "RUNNERUP_DIRS", (inbox, done, tmp_path / "x")
|
||||
):
|
||||
runs = _activity_sources.runnerup_sessions("2026-09-01")
|
||||
assert [(s.kind, s.minutes, s.km) for s in runs] == [
|
||||
("run", 40.0, 5.0),
|
||||
("walk", 40.0, None),
|
||||
]
|
||||
assert runs[0].start is not None
|
||||
assert runs[0].end is not None
|
||||
|
||||
|
||||
class TestScreenLockerLogFile:
|
||||
def test_absent_package(self) -> None:
|
||||
with patch.object(_activity_sources, "import_module", side_effect=ImportError):
|
||||
assert _REAL_LOG_FILE() is None
|
||||
|
||||
def test_package_with_and_without_a_log(self, tmp_path: Path) -> None:
|
||||
pkg = SimpleNamespace(__file__=str(tmp_path / "__init__.py"))
|
||||
with patch.object(_activity_sources, "import_module", return_value=pkg):
|
||||
assert _REAL_LOG_FILE() is None
|
||||
(tmp_path / "log.json").write_text("{}", encoding="utf-8")
|
||||
assert _REAL_LOG_FILE() == tmp_path / "log.json"
|
||||
|
||||
|
||||
class TestScreenLocker:
|
||||
def _run(
|
||||
self, tmp_path: Path, log: dict[str, object], tcx_days: set[str]
|
||||
) -> list[object]:
|
||||
path = tmp_path / "log.json"
|
||||
path.write_text(json.dumps(log), encoding="utf-8")
|
||||
with (
|
||||
patch.object(
|
||||
_activity_sources, "screen_locker_log_file", return_value=path
|
||||
),
|
||||
patch.object(_activity_sources, "import_module", return_value=_FAKE_LOG_IO),
|
||||
):
|
||||
return _activity_sources.screen_locker_sessions("2026-09-01", tcx_days)
|
||||
|
||||
def test_every_entry_type(self, tmp_path: Path) -> None:
|
||||
log = {
|
||||
"2026-08-01": [
|
||||
_entry({"type": "pc_workout_verified", "duration_minutes": "50"})
|
||||
],
|
||||
"2026-09-20": [
|
||||
_entry(
|
||||
{
|
||||
"type": "pc_workout_verified",
|
||||
"source": "SL B (86 min, partial)",
|
||||
"duration_minutes": "86.2",
|
||||
}
|
||||
),
|
||||
_entry(
|
||||
{"type": "phone_verified", "source": "Workout verified! (86 min)"}
|
||||
),
|
||||
_entry({"type": "relaxed_day_skip"}),
|
||||
_entry(
|
||||
{
|
||||
"type": "manual_workout",
|
||||
"activity_type": "Walking",
|
||||
"duration_minutes": "60",
|
||||
"start_time": "19:15",
|
||||
"end_time": "20:15",
|
||||
}
|
||||
),
|
||||
{"workout_data": "junk"},
|
||||
],
|
||||
"2026-09-21": [
|
||||
_entry(
|
||||
{"type": "phone_verified", "source": "Workout verified! (70 min)"}
|
||||
)
|
||||
],
|
||||
"2026-09-22": [
|
||||
_entry(
|
||||
{
|
||||
"type": "runnerup_verified",
|
||||
"source": "Auto-scanned: Running: 5.6 km in 60 min",
|
||||
"distance_km": 5.61,
|
||||
"duration_minutes": 60.5,
|
||||
}
|
||||
)
|
||||
],
|
||||
"2026-09-23": [
|
||||
_entry(
|
||||
{
|
||||
"type": "runnerup_verified",
|
||||
"source": "Auto-scanned",
|
||||
"duration_minutes": "x",
|
||||
}
|
||||
)
|
||||
],
|
||||
"2026-09-24": [
|
||||
_entry(
|
||||
{
|
||||
"type": "runnerup_verified",
|
||||
"source": "odd",
|
||||
"duration_minutes": 30,
|
||||
"distance_km": "far",
|
||||
}
|
||||
)
|
||||
],
|
||||
"2026-09-25": [
|
||||
_entry({"type": "runnerup_verified", "duration_minutes": 30})
|
||||
],
|
||||
}
|
||||
sessions = self._run(tmp_path, log, {"2026-09-25"})
|
||||
summary = [(s.day, s.kind, s.minutes, s.km) for s in sessions]
|
||||
assert summary == [
|
||||
("2026-09-20", "strength", 86.2, None),
|
||||
("2026-09-20", "walk", 60.0, None),
|
||||
("2026-09-21", "strength", 70.0, None),
|
||||
("2026-09-22", "run", 60.5, 5.61),
|
||||
("2026-09-24", "run", 30.0, None),
|
||||
]
|
||||
|
||||
def test_absent_or_unreadable_log(self, tmp_path: Path) -> None:
|
||||
assert _activity_sources.screen_locker_sessions("2026-09-01", set()) == []
|
||||
with (
|
||||
patch.object(
|
||||
_activity_sources, "screen_locker_log_file", return_value=tmp_path
|
||||
),
|
||||
patch.object(_activity_sources, "import_module", side_effect=ImportError),
|
||||
):
|
||||
assert _activity_sources.screen_locker_sessions("2026-09-01", set()) == []
|
||||
|
||||
|
||||
class TestCollect:
|
||||
def test_tcx_day_suppresses_the_duplicate_scan(self, tmp_path: Path) -> None:
|
||||
_tcx(tmp_path / "r", "a.tcx", "2026-09-22T17:00:00Z")
|
||||
log = tmp_path / "log.json"
|
||||
log.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"2026-09-22": [
|
||||
_entry({"type": "runnerup_verified", "duration_minutes": 60})
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch.object(_activity_sources, "RUNNERUP_DIRS", (tmp_path / "r",)),
|
||||
patch.object(_activity_sources, "screen_locker_log_file", return_value=log),
|
||||
patch.object(_activity_sources, "import_module", return_value=_FAKE_LOG_IO),
|
||||
):
|
||||
sessions = _activity_sources.collect_sessions(date(2026, 9, 28))
|
||||
assert _activity_sources.sources_available()
|
||||
assert [s.source for s in sessions] == ["runnerup"]
|
||||
|
||||
def test_no_sources(self) -> None:
|
||||
assert not _activity_sources.sources_available()
|
||||
168
diet_guard/tests/test_body_calc.py
Normal file
168
diet_guard/tests/test_body_calc.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""Edge cases of the Body formulas the shared parity fixture does not reach."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard._activity_kcal import (
|
||||
Session,
|
||||
daily_exercise,
|
||||
met_for,
|
||||
session_kcal,
|
||||
window_days,
|
||||
)
|
||||
from diet_guard._body_calc import (
|
||||
bmi_report,
|
||||
bmr_formulas,
|
||||
half_up,
|
||||
ideal_summary,
|
||||
table_bmr,
|
||||
)
|
||||
from diet_guard._body_energy import (
|
||||
MEASURED_PREFIX,
|
||||
Goal,
|
||||
Target,
|
||||
activity_label,
|
||||
goal_target,
|
||||
)
|
||||
from diet_guard._budget_biometrics import Biometrics
|
||||
from diet_guard._week_plan import plan_week, week_start
|
||||
|
||||
_MAN = Biometrics(weight_kg=80.0, height_cm=180.0, age_years=30, is_male=True)
|
||||
_TODAY = date(2026, 9, 28)
|
||||
|
||||
|
||||
def _session(
|
||||
day: str, kind: str = "run", minutes: float = 60.0, km: float | None = 10.0
|
||||
) -> Session:
|
||||
return Session(day, kind, minutes, km, "x", "runnerup")
|
||||
|
||||
|
||||
class TestHalfUp:
|
||||
def test_rounds_halves_up_unlike_python_round(self) -> None:
|
||||
assert half_up(2.5) == 3
|
||||
assert round(2.5) == 2
|
||||
assert half_up(1.25, 1) == 1.3
|
||||
|
||||
|
||||
class TestBmiCategory:
|
||||
@pytest.mark.parametrize(
|
||||
("kg", "label"),
|
||||
[
|
||||
(45.0, "severe thinness"),
|
||||
(52.0, "moderate thinness"),
|
||||
(58.0, "mild thinness"),
|
||||
(70.0, "normal"),
|
||||
(90.0, "overweight"),
|
||||
(100.0, "obese class I"),
|
||||
(120.0, "obese class II"),
|
||||
(140.0, "obese class III"),
|
||||
],
|
||||
)
|
||||
def test_every_who_band(self, kg: float, label: str) -> None:
|
||||
bio = Biometrics(weight_kg=kg, height_cm=180.0, age_years=30, is_male=True)
|
||||
assert bmi_report(bio).category == label
|
||||
|
||||
|
||||
class TestBmr:
|
||||
def test_body_fat_formulas_are_none_without_a_reading(self) -> None:
|
||||
names = {f.name: f.value for f in bmr_formulas(_MAN)}
|
||||
assert names["Katch-McArdle"] is None
|
||||
assert names["Cunningham"] is None
|
||||
|
||||
def test_table_bmr_switches_to_katch_mcardle_with_body_fat(self) -> None:
|
||||
assert table_bmr(_MAN, None).name == "Mifflin-St Jeor"
|
||||
lean = table_bmr(_MAN, 20.0)
|
||||
assert lean.name == "Katch-McArdle"
|
||||
assert lean.value == pytest.approx(370 + 21.6 * 64.0)
|
||||
|
||||
def test_ideal_summary_is_none_when_nothing_applies(self) -> None:
|
||||
assert ideal_summary(()) is None
|
||||
|
||||
|
||||
class TestGoal:
|
||||
def test_kg_per_day_signs(self) -> None:
|
||||
assert Goal("lose", 0.7, "kg", "week", "light").kg_per_day() == pytest.approx(
|
||||
0.1
|
||||
)
|
||||
assert Goal("gain", 0.7, "kg", "week", "light").kg_per_day() == pytest.approx(
|
||||
-0.1
|
||||
)
|
||||
assert Goal("maintain", 5.0, "kg", "day", "light").kg_per_day() == 0.0
|
||||
|
||||
def test_unknown_activity_has_no_target(self) -> None:
|
||||
target = goal_target(Goal("lose", 1, "kg", "week", "bogus"), 1800, "M", None)
|
||||
assert target == Target(kcal=None, tdee=None, bmr_name="M")
|
||||
assert not target.below_floor
|
||||
|
||||
def test_measured_needs_that_formula(self) -> None:
|
||||
goal = Goal("lose", 1, "kg", "week", f"{MEASURED_PREFIX}MET")
|
||||
assert goal_target(goal, 1800, "M", None).kcal is None
|
||||
assert goal_target(goal, 1800, "M", {"ACSM": 100.0}).kcal is None
|
||||
assert goal_target(goal, 1800, "M", {"MET": 100.0}).tdee == pytest.approx(2260)
|
||||
|
||||
def test_below_floor(self) -> None:
|
||||
target = goal_target(
|
||||
Goal("lose", 2, "kg", "week", "sedentary"), 1500, "M", None
|
||||
)
|
||||
assert target.below_floor
|
||||
|
||||
def test_activity_labels(self) -> None:
|
||||
assert activity_label("moderate") == "Moderate"
|
||||
assert activity_label(f"{MEASURED_PREFIX}ACSM") == "Measured (ACSM)"
|
||||
assert activity_label("mystery") == "mystery"
|
||||
|
||||
|
||||
class TestSessions:
|
||||
def test_met_is_clamped_at_both_ends_of_the_speed_table(self) -> None:
|
||||
assert met_for(_session("d", km=1.0)) == 6.0 # 1 km/h
|
||||
assert met_for(_session("d", km=40.0)) == 23.0 # 40 km/h
|
||||
assert met_for(_session("d", kind="walk", km=2.0)) == 2.8
|
||||
assert met_for(_session("d", kind="walk", km=100.0)) == 8.3
|
||||
|
||||
def test_fixed_mets_without_a_distance(self) -> None:
|
||||
assert met_for(_session("d", kind="cycle", km=None)) == 7.5
|
||||
assert met_for(_session("d", kind="yoga", km=None)) == 4.0
|
||||
assert met_for(_session("d", minutes=0.0)) == 8.0
|
||||
|
||||
def test_no_distance_falls_back_to_met(self) -> None:
|
||||
result = session_kcal(_session("d", kind="walk", km=None), 70.0)
|
||||
assert result.fallback == {"ACSM", "Per-km"}
|
||||
assert len(set(result.kcal.values())) == 1
|
||||
|
||||
|
||||
class TestWindow:
|
||||
def test_full_window_once_history_is_old_enough(self) -> None:
|
||||
assert window_days((_session("2026-09-01"),), _TODAY) == 14
|
||||
|
||||
def test_short_history_counts_from_its_first_day(self) -> None:
|
||||
assert window_days((_session("2026-09-25"),), _TODAY) == 3
|
||||
extra = {"2026-09-26": 50.0}
|
||||
assert window_days((), _TODAY, extra) == 2
|
||||
|
||||
def test_today_never_counts(self) -> None:
|
||||
assert window_days((_session("2026-09-28"),), _TODAY) == 0
|
||||
zero = daily_exercise((_session("2026-09-28"),), 80.0, _TODAY)
|
||||
assert zero == {"MET": 0.0, "ACSM": 0.0, "Per-km": 0.0}
|
||||
|
||||
def test_extra_days_outside_the_window_are_ignored(self) -> None:
|
||||
extra = {"2026-09-26": 20.0, "2026-09-01": 999.0, "2026-09-28": 999.0}
|
||||
result = daily_exercise((), 80.0, _TODAY, extra)
|
||||
assert result["MET"] == pytest.approx(20.0 / 14)
|
||||
|
||||
def test_short_history_average_uses_the_short_span(self) -> None:
|
||||
one_day = daily_exercise((), 80.0, _TODAY, {"2026-09-27": 90.0})
|
||||
assert one_day["MET"] == pytest.approx(90.0)
|
||||
|
||||
|
||||
class TestWeekPlan:
|
||||
def test_week_starts_on_monday(self) -> None:
|
||||
assert week_start(date(2026, 10, 4)) == date(2026, 9, 28)
|
||||
|
||||
def test_below_floor_only_with_remaining_days(self) -> None:
|
||||
low = plan_week(date(2026, 10, 1), 1500, {"2026-09-28": 5000}, {})
|
||||
assert low.below_floor
|
||||
full = plan_week(date(2026, 10, 4), 1500, {}, dict.fromkeys(range(7), 1500))
|
||||
assert not full.below_floor
|
||||
102
diet_guard/tests/test_body_parity.py
Normal file
102
diet_guard/tests/test_body_parity.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Python half of the Body parity gate: every formula against the fixture.
|
||||
|
||||
``tests/fixtures/body_calc.json`` is generated from this implementation by
|
||||
``scripts/build_body_fixture.py`` and asserted value-for-value by the Dart
|
||||
suite too (``app/test/services/body_parity_test.dart``). This half guards the
|
||||
fixture against drifting from the code it claims to describe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import date
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard._activity_kcal import Session, daily_exercise, session_kcal
|
||||
from diet_guard._body_calc import (
|
||||
bmi_report,
|
||||
bmr_formulas,
|
||||
healthy_range,
|
||||
ideal_summary,
|
||||
ideal_weights,
|
||||
table_bmr,
|
||||
)
|
||||
from diet_guard._body_energy import goal_target
|
||||
from diet_guard._body_prefs import goal_from_json
|
||||
from diet_guard._body_steps import step_kcal, stride_m
|
||||
from diet_guard._budget_biometrics import Biometrics
|
||||
from diet_guard._week_plan import plan_week
|
||||
|
||||
_FIXTURE = Path(__file__).resolve().parents[2] / "tests/fixtures/body_calc.json"
|
||||
_DATA = json.loads(_FIXTURE.read_text(encoding="utf-8"))
|
||||
_TODAY = date.fromisoformat(_DATA["today"])
|
||||
_SESSIONS = tuple(Session(**raw) for raw in _DATA["sessions"])
|
||||
|
||||
|
||||
def _same(actual: object, expected: object) -> None:
|
||||
"""Deep equality with float tolerance (JSON turns tuples into lists)."""
|
||||
if isinstance(expected, float) or isinstance(actual, float):
|
||||
assert actual == pytest.approx(expected, rel=1e-12, abs=1e-9)
|
||||
elif isinstance(expected, dict):
|
||||
assert isinstance(actual, dict)
|
||||
assert set(actual) == set(expected)
|
||||
for key, value in expected.items():
|
||||
_same(actual[key], value)
|
||||
elif isinstance(expected, list):
|
||||
assert isinstance(actual, (list, tuple))
|
||||
assert len(actual) == len(expected)
|
||||
for got, want in zip(actual, expected, strict=True):
|
||||
_same(got, want)
|
||||
else:
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("person", _DATA["people"], ids=lambda p: str(p["bio"]))
|
||||
def test_person_matches_the_fixture(person: dict[str, object]) -> None:
|
||||
bio = Biometrics(**person["bio"]) # type-checked by the dataclass itself
|
||||
fat = person["body_fat_pct"]
|
||||
steps = {day: step_kcal(n, bio) for day, n in _DATA["steps"].items()}
|
||||
_same(asdict(bmi_report(bio)), person["bmi"])
|
||||
_same([[f.name, f.value] for f in ideal_weights(bio)], person["ideal"])
|
||||
_same([[f.name, f.value] for f in bmr_formulas(bio)], person["bmr"])
|
||||
_same([[f.name, f.value] for f in bmr_formulas(bio, fat)], person["bmr_with_fat"])
|
||||
_same(ideal_summary(ideal_weights(bio)), person["ideal_summary"])
|
||||
_same(healthy_range(bio), person["healthy_range"])
|
||||
_same(stride_m(bio), person["stride_m"])
|
||||
_same(steps, person["step_kcal"])
|
||||
exercise = daily_exercise(_SESSIONS, bio.weight_kg, _TODAY, steps)
|
||||
_same(exercise, person["exercise"])
|
||||
_same(daily_exercise(_SESSIONS, bio.weight_kg, _TODAY), person["exercise_no_steps"])
|
||||
short = tuple(s for s in _SESSIONS if s.day >= "2026-09-24")
|
||||
_same(daily_exercise(short, bio.weight_kg, _TODAY), person["exercise_short"])
|
||||
only_today = tuple(s for s in _SESSIONS if s.day == "2026-09-28")
|
||||
_same(
|
||||
daily_exercise(only_today, bio.weight_kg, _TODAY),
|
||||
person["exercise_today_only"],
|
||||
)
|
||||
for row in person["targets"]:
|
||||
bmr = table_bmr(bio, row["body_fat_pct"])
|
||||
target = goal_target(
|
||||
goal_from_json(row["goal"]), bmr.value or 0.0, bmr.name, exercise
|
||||
)
|
||||
expected = {k: v for k, v in row.items() if k not in {"goal", "body_fat_pct"}}
|
||||
_same(asdict(target), expected)
|
||||
|
||||
|
||||
def test_session_kcal_matches_the_fixture() -> None:
|
||||
for session, expected in zip(_SESSIONS, _DATA["session_kcal_80kg"], strict=True):
|
||||
result = session_kcal(session, 80.0)
|
||||
_same(result.kcal, expected["kcal"])
|
||||
assert sorted(result.fallback) == expected["fallback"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plan", _DATA["plans"], ids=lambda p: p["today"])
|
||||
def test_week_plans_match_the_fixture(plan: dict[str, object]) -> None:
|
||||
typed = {int(k): v for k, v in plan["typed"].items()}
|
||||
result = plan_week(
|
||||
date.fromisoformat(plan["today"]), plan["target"], plan["logged"], typed
|
||||
)
|
||||
_same(asdict(result), plan["expected"])
|
||||
138
diet_guard/tests/test_body_report.py
Normal file
138
diet_guard/tests/test_body_report.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""The text renderer and the view it renders, over seeded Body state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from diet_guard import _sync_body
|
||||
from diet_guard._activity_kcal import Session, window_days
|
||||
from diet_guard._body_activity_store import set_activity
|
||||
from diet_guard._body_ingest import weight_candidates
|
||||
from diet_guard._body_prefs import goal_from_json
|
||||
from diet_guard._body_report import (
|
||||
activity_lines,
|
||||
bodyfat_lines,
|
||||
ideal_lines,
|
||||
plan_lines,
|
||||
target_lines,
|
||||
)
|
||||
from diet_guard._body_store import read_body, set_profile, set_weight, write_body
|
||||
from diet_guard._body_view import build_view
|
||||
from diet_guard._week_plan import plan_week
|
||||
|
||||
_TODAY = date(2026, 9, 28)
|
||||
|
||||
|
||||
def _person() -> None:
|
||||
set_profile(birth="2000-05-22", height_cm=169.0, sex="m")
|
||||
set_weight("2026-09-27", 72.6)
|
||||
|
||||
|
||||
class TestView:
|
||||
def test_empty_view(self) -> None:
|
||||
view = build_view(_TODAY)
|
||||
assert view.weight is None
|
||||
assert view.numbers is None
|
||||
assert view.exercise is None
|
||||
assert activity_lines(view) == [
|
||||
"No workouts published yet (the PC publishes them on sync)."
|
||||
]
|
||||
assert target_lines(view)[0].startswith("Set birth date")
|
||||
assert ideal_lines(view)[0].startswith("Set birth date")
|
||||
|
||||
def test_published_workouts_and_steps(self) -> None:
|
||||
_person()
|
||||
set_activity(
|
||||
(
|
||||
Session("2026-09-22", "run", 60.0, 6.0, "Run", "runnerup"),
|
||||
Session("2026-09-25", "strength", 80.0, None, "SL", "screen-locker"),
|
||||
)
|
||||
)
|
||||
doc = read_body()
|
||||
doc["steps"] = {"2026-09-26": {"n": 9000, "t": "2026-09-26T22:00:00+02:00"}}
|
||||
write_body(doc)
|
||||
view = build_view(_TODAY)
|
||||
assert view.weight == 72.6
|
||||
assert view.exercise is not None
|
||||
assert view.exercise.days == 6
|
||||
lines = activity_lines(view)
|
||||
assert lines[1].startswith("Last 6 full days, net exercise kcal/day: MET")
|
||||
assert lines[2].startswith("Steps outside workouts: 9000 on 2026-09-26")
|
||||
assert any(" run " in line and "6.0 km M" in line for line in lines)
|
||||
|
||||
def test_steps_alone_show_without_a_publish_stamp(self) -> None:
|
||||
_person()
|
||||
doc = read_body()
|
||||
doc["steps"] = {"2026-09-27": {"n": 3000, "t": "2026-09-27T22:00:00+02:00"}}
|
||||
write_body(doc)
|
||||
view = build_view(_TODAY)
|
||||
assert activity_lines(view)[0] == "Workouts published not yet"
|
||||
|
||||
def test_sessions_without_a_weight_have_no_kcal(self) -> None:
|
||||
set_activity((Session("2026-09-22", "run", 60.0, None, "Run", "runnerup"),))
|
||||
line = activity_lines(build_view(_TODAY))[1]
|
||||
assert line.endswith("min")
|
||||
|
||||
|
||||
class TestTargetAndIdeal:
|
||||
def test_missing_summary_skips_average_rows(self) -> None:
|
||||
_person()
|
||||
view = build_view(_TODAY)
|
||||
assert view.numbers is not None
|
||||
bare = replace(view, numbers=replace(view.numbers, ideal_summary=None))
|
||||
assert not any("Average of all" in line for line in ideal_lines(bare))
|
||||
|
||||
def test_body_fat_lines(self) -> None:
|
||||
assert bodyfat_lines(build_view(_TODAY))[0].startswith("Body fat: not logged")
|
||||
|
||||
|
||||
class TestPlanLines:
|
||||
def test_all_known_and_unlogged_and_floor(self) -> None:
|
||||
full = plan_week(date(2026, 10, 4), 1800, {}, dict.fromkeys(range(7), 1900))
|
||||
assert plan_lines(full)[-1].startswith("Every day is known")
|
||||
low = plan_week(date(2026, 10, 1), 1500, {"2026-09-28": 5000}, {})
|
||||
lines = plan_lines(low)
|
||||
assert "(no log - type it?)" in lines[2]
|
||||
assert lines[-1].endswith("floor!")
|
||||
|
||||
|
||||
class TestLeftoverBranches:
|
||||
def test_future_extra_days_are_ignored(self) -> None:
|
||||
assert window_days((), _TODAY, {"2026-09-28": 5.0}) == 0
|
||||
|
||||
def test_an_older_candidate_does_not_replace_a_newer_one(self) -> None:
|
||||
newer = json.dumps(
|
||||
{
|
||||
"alarm": {
|
||||
"fields": {
|
||||
"morning_sessions": [
|
||||
[{"date": "2026-09-27", "weight_kg": 71}],
|
||||
"x",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
older_hlc = "1970-01-01T00:00:00.000Z-0000-a"
|
||||
older = (
|
||||
'{"alarm": {"fields": {"latest_weight_kg": '
|
||||
f'[{{"date": "2026-09-27", "kg": 70}}, "{older_hlc}"]}}}}}}'
|
||||
)
|
||||
cands = weight_candidates([("n", newer), ("o", older)])
|
||||
assert [c.kg for c in cands] == [71.0]
|
||||
|
||||
def test_unknown_time_unit_keeps_the_default(self) -> None:
|
||||
assert goal_from_json({"tu": "fortnight"}).time_unit == "week"
|
||||
|
||||
def test_unchanged_activity_is_not_republished(self) -> None:
|
||||
with (
|
||||
patch.object(_sync_body, "sources_available", return_value=True),
|
||||
patch.object(_sync_body, "collect_sessions", return_value=()),
|
||||
patch.object(_sync_body, "set_activity", return_value=False),
|
||||
patch.object(_sync_body._logger, "info") as info,
|
||||
):
|
||||
_sync_body.publish_activity()
|
||||
info.assert_not_called()
|
||||
232
diet_guard/tests/test_body_store.py
Normal file
232
diet_guard/tests/test_body_store.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""The Body document on disk: profile, weights, body fat, sessions, goal, steps.
|
||||
|
||||
``BODY_FILE`` is redirected into ``tmp_path`` by conftest's ``_isolate_state``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard import _body_store
|
||||
from diet_guard._activity_kcal import Session
|
||||
from diet_guard._body_activity_store import (
|
||||
activity,
|
||||
session_from_json,
|
||||
session_to_json,
|
||||
set_activity,
|
||||
)
|
||||
from diet_guard._body_energy import DEFAULT_GOAL, Goal
|
||||
from diet_guard._body_fat_store import body_fats, newest_body_fat, set_body_fat
|
||||
from diet_guard._body_prefs import goal_from_json, saved_goal, set_goal
|
||||
from diet_guard._body_steps import daily_steps
|
||||
from diet_guard._body_store import (
|
||||
Profile,
|
||||
WeightCandidate,
|
||||
age_on,
|
||||
apply_weight_candidates,
|
||||
newest_weight,
|
||||
profile,
|
||||
read_body,
|
||||
set_profile,
|
||||
set_weight,
|
||||
stamp_ms,
|
||||
weights,
|
||||
write_body,
|
||||
)
|
||||
|
||||
|
||||
def _raw(doc: object) -> None:
|
||||
_body_store.BODY_FILE.write_text(json.dumps(doc), encoding="utf-8")
|
||||
|
||||
|
||||
def _cell(value: object) -> dict[str, object]:
|
||||
"""Narrow a document cell (typed ``object``) for indexing."""
|
||||
assert isinstance(value, dict)
|
||||
return value
|
||||
|
||||
|
||||
class TestReadBody:
|
||||
def test_missing_file_reads_as_empty_sections(self) -> None:
|
||||
assert read_body() == {s: {} for s in _body_store.SECTIONS}
|
||||
|
||||
def test_corrupt_or_wrong_shape_reads_as_empty(self) -> None:
|
||||
_body_store.BODY_FILE.write_text("{not json", encoding="utf-8")
|
||||
assert read_body()["weights"] == {}
|
||||
_raw([1, 2])
|
||||
assert read_body()["profile"] == {}
|
||||
_raw({"weights": "nope"})
|
||||
assert read_body()["weights"] == {}
|
||||
|
||||
def test_write_then_read_round_trips(self) -> None:
|
||||
doc = read_body()
|
||||
doc["weights"]["2026-09-01"] = {"kg": 80.0, "src": "manual", "t": "x"}
|
||||
write_body(doc)
|
||||
assert json.loads(_body_store.BODY_FILE.read_text())["v"] == 1
|
||||
assert _cell(read_body()["weights"]["2026-09-01"])["kg"] == 80.0
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_age_counts_completed_years(self) -> None:
|
||||
assert age_on(date(2000, 5, 22), date(2026, 5, 21)) == 25
|
||||
assert age_on(date(2000, 5, 22), date(2026, 5, 22)) == 26
|
||||
|
||||
def test_stamp_ms(self) -> None:
|
||||
assert stamp_ms("1970-01-01T00:00:01+00:00") == 1000
|
||||
assert stamp_ms("1970-01-01T00:00:02") == 2000 # naive = UTC
|
||||
assert stamp_ms("garbage") == 0
|
||||
assert stamp_ms(None) == 0
|
||||
|
||||
|
||||
class TestProfile:
|
||||
def test_empty_profile_is_incomplete(self) -> None:
|
||||
prof = profile()
|
||||
assert not prof.complete
|
||||
assert prof.biometrics(80.0, date(2026, 9, 28)) is None
|
||||
|
||||
def test_set_profile_and_biometrics(self) -> None:
|
||||
set_profile(birth="2000-05-22", height_cm=169.0, sex="m")
|
||||
prof = profile()
|
||||
assert prof.complete
|
||||
bio = prof.biometrics(72.6, date(2026, 9, 28))
|
||||
assert bio is not None
|
||||
assert bio.age_years == 26
|
||||
assert bio.is_male
|
||||
|
||||
def test_malformed_fields_read_as_unset(self) -> None:
|
||||
_raw(
|
||||
{
|
||||
"profile": {
|
||||
"birth": {"v": "not-a-date"},
|
||||
"height_cm": {"v": True},
|
||||
"sex": {"v": "x"},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert profile() == Profile(birth=None, height_cm=None, sex=None)
|
||||
_raw({"profile": {"birth": {"v": 12}, "height_cm": {"v": -5}, "sex": "m"}})
|
||||
assert profile() == Profile(birth=None, height_cm=None, sex=None)
|
||||
|
||||
|
||||
class TestWeights:
|
||||
def test_set_list_newest_and_delete(self) -> None:
|
||||
assert newest_weight() is None
|
||||
set_weight("2026-09-01", 80.123)
|
||||
set_weight("2026-09-10", 79.0)
|
||||
assert weights() == {"2026-09-01": 80.12, "2026-09-10": 79.0}
|
||||
set_weight("2026-09-10", None)
|
||||
assert newest_weight() == ("2026-09-01", 80.12)
|
||||
|
||||
def test_bad_keys_and_values_are_skipped(self) -> None:
|
||||
_raw(
|
||||
{
|
||||
"weights": {
|
||||
"garbage": {"kg": 70},
|
||||
"2026-09-01": {"kg": "x"},
|
||||
"2026-09-02": 5,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert weights() == {}
|
||||
|
||||
def test_candidates_apply_only_when_newer(self) -> None:
|
||||
set_weight("2026-09-27", 73.0) # stamped now
|
||||
applied = apply_weight_candidates(
|
||||
[
|
||||
WeightCandidate(
|
||||
"2026-09-27", 72.0, "phone", "2026-09-27T00:00:00+00:00"
|
||||
),
|
||||
WeightCandidate(
|
||||
"2026-09-26", 72.5, "phone", "2026-09-26T00:00:00+00:00"
|
||||
),
|
||||
]
|
||||
)
|
||||
assert applied == 1
|
||||
assert weights() == {"2026-09-26": 72.5, "2026-09-27": 73.0}
|
||||
assert apply_weight_candidates([]) == 0
|
||||
|
||||
|
||||
class TestBodyFat:
|
||||
def test_log_newest_and_delete(self) -> None:
|
||||
assert newest_body_fat() is None
|
||||
set_body_fat("2026-09-20", 17.55)
|
||||
set_body_fat("2026-09-27", 16.8)
|
||||
assert body_fats() == {"2026-09-20": 17.6, "2026-09-27": 16.8}
|
||||
set_body_fat("2026-09-27", None)
|
||||
assert newest_body_fat() == 17.6
|
||||
|
||||
def test_malformed_cells_are_skipped(self) -> None:
|
||||
_raw({"bodyfat": {"2026-09-01": 3, "bad": {"pct": 10}}})
|
||||
assert body_fats() == {}
|
||||
|
||||
|
||||
class TestSessions:
|
||||
def test_round_trip_with_window(self) -> None:
|
||||
s = Session(
|
||||
"2026-09-23",
|
||||
"run",
|
||||
60.0,
|
||||
5.6,
|
||||
"Run",
|
||||
"runnerup",
|
||||
"2026-09-23T19:05:43+02:00",
|
||||
"2026-09-23T20:05:43+02:00",
|
||||
)
|
||||
assert session_from_json(session_to_json(s)) == s
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"x",
|
||||
{"day": 1, "kind": "run", "min": 5},
|
||||
{"day": "d", "kind": "run", "min": 0},
|
||||
{"day": "d", "kind": "run", "min": 5, "km": "far"},
|
||||
],
|
||||
)
|
||||
def test_malformed_sessions_are_none(self, raw: object) -> None:
|
||||
assert session_from_json(raw) is None
|
||||
|
||||
def test_bad_window_stamps_read_as_none(self) -> None:
|
||||
s = session_from_json(
|
||||
{"day": "d", "kind": "run", "min": 5, "start": 3, "end": "nope"}
|
||||
)
|
||||
assert s is not None
|
||||
assert s.start is None
|
||||
assert s.end is None
|
||||
|
||||
def test_activity_publish_is_idempotent(self) -> None:
|
||||
assert activity() == ((), None)
|
||||
s = Session("2026-09-23", "strength", 80.0, None, "SL", "screen-locker")
|
||||
assert set_activity((s,)) is True
|
||||
assert set_activity((s,)) is False
|
||||
sessions, stamp = activity()
|
||||
assert sessions == (s,)
|
||||
assert stamp is not None
|
||||
|
||||
def test_malformed_activity_reads_as_none(self) -> None:
|
||||
_raw({"activity": {"sessions": "x", "t": "2026"}})
|
||||
assert activity() == ((), None)
|
||||
|
||||
|
||||
class TestGoalPrefs:
|
||||
def test_default_until_saved(self) -> None:
|
||||
assert saved_goal() == DEFAULT_GOAL
|
||||
goal = Goal("gain", 2.0, "lb", "month", "measured:ACSM")
|
||||
set_goal(goal)
|
||||
assert saved_goal() == goal
|
||||
|
||||
def test_each_part_falls_back_on_its_own(self) -> None:
|
||||
assert goal_from_json("x") == DEFAULT_GOAL
|
||||
mixed = goal_from_json(
|
||||
{"dir": "sideways", "amt": -1, "wu": "stone?", "tu": "week", "act": "?"}
|
||||
)
|
||||
assert mixed == DEFAULT_GOAL
|
||||
assert goal_from_json({"amt": 3, "tu": "year"}).time_unit == "year"
|
||||
|
||||
|
||||
class TestSteps:
|
||||
def test_daily_steps_reads_live_days(self) -> None:
|
||||
_raw({"steps": {"2026-09-27": {"n": 8421.0}, "bad": {"n": 5}, "2026-09-26": 4}})
|
||||
assert daily_steps() == {"2026-09-27": 8421}
|
||||
221
diet_guard/tests/test_body_sync.py
Normal file
221
diet_guard/tests/test_body_sync.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""Body sync: wake-alarm weigh-in ingestion, the Log adapter, and the tick."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from crdt_sync import Hlc, MirrorSyncClient, RemoteSyncError, merge_logs
|
||||
import pytest
|
||||
|
||||
from diet_guard import _sync_body
|
||||
from diet_guard._body_ingest import fetch_alarm_logs, weight_candidates
|
||||
from diet_guard._body_store import read_body, set_profile, set_weight, weights
|
||||
from diet_guard._device import device_id
|
||||
from diet_guard.sync_merge._body import (
|
||||
body_to_log,
|
||||
encode_body_for_push,
|
||||
log_to_body,
|
||||
parse_remote_body,
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
def _alarm(latest: object = None, sessions: object = None, hlc: str = _HLC) -> str:
|
||||
fields: dict[str, object] = {}
|
||||
if latest is not None:
|
||||
fields["latest_weight_kg"] = [latest, hlc]
|
||||
if sessions is not None:
|
||||
fields["morning_sessions"] = [sessions, hlc]
|
||||
return json.dumps({"alarm": {"fields": fields}})
|
||||
|
||||
|
||||
def _cell(value: object) -> dict[str, object]:
|
||||
"""Narrow a document cell (typed ``object``) for indexing."""
|
||||
assert isinstance(value, dict)
|
||||
return value
|
||||
|
||||
|
||||
class TestWeightCandidates:
|
||||
def test_latest_uses_its_hlc_and_sessions_fill_gaps(self) -> None:
|
||||
cands = weight_candidates(
|
||||
[
|
||||
(
|
||||
"phone",
|
||||
_alarm(
|
||||
{"date": "2026-09-27", "kg": 72.6},
|
||||
[
|
||||
{"date": "2026-09-26", "weight_kg": 72.8},
|
||||
{"date": "2026-09-27", "weight_kg": 72.9},
|
||||
{"date": "2026-09-25", "weight_kg": 500},
|
||||
"junk",
|
||||
],
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
by_day = {c.day: c for c in cands}
|
||||
assert set(by_day) == {"2026-09-26", "2026-09-27"}
|
||||
assert by_day["2026-09-27"].kg == 72.6 # the HLC-stamped value wins
|
||||
assert by_day["2026-09-26"].t == "2026-09-26T00:00:00+00:00"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"not json",
|
||||
json.dumps([1]),
|
||||
json.dumps({"alarm": {"fields": {"latest_weight_kg": "short"}}}),
|
||||
_alarm({"date": "2026-09-27", "kg": True}),
|
||||
_alarm({"date": 5, "kg": 70}),
|
||||
_alarm({"date": "2026-09-27", "kg": 70}, hlc="bad-hlc"),
|
||||
],
|
||||
)
|
||||
def test_unusable_logs_offer_nothing(self, raw: str) -> None:
|
||||
assert weight_candidates([("phone", raw)]) == []
|
||||
|
||||
def test_newest_stamp_wins_across_devices(self) -> None:
|
||||
older = Hlc(wall_time_ms=1_000, counter=0, node_id="a").to_str()
|
||||
cands = weight_candidates(
|
||||
[
|
||||
("a", _alarm({"date": "2026-09-27", "kg": 70.0}, hlc=older)),
|
||||
("b", _alarm({"date": "2026-09-27", "kg": 71.0})),
|
||||
]
|
||||
)
|
||||
assert [c.kg for c in cands] == [71.0]
|
||||
|
||||
|
||||
class TestFetchAlarmLogs:
|
||||
def test_reads_every_device_and_skips_missing_files(self) -> None:
|
||||
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:
|
||||
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:
|
||||
store = MagicMock()
|
||||
store.list_directory.side_effect = RemoteSyncError("down")
|
||||
assert fetch_alarm_logs(store) == []
|
||||
|
||||
|
||||
class TestLogAdapter:
|
||||
def test_round_trip_every_section(self) -> None:
|
||||
set_profile(birth="2000-05-22", sex="m")
|
||||
set_weight("2026-09-27", 72.6, "phone")
|
||||
doc = read_body()
|
||||
doc["bodyfat"]["2026-09-27"] = {"pct": 16.8, "t": "2026-09-27T10:00:00+02:00"}
|
||||
doc["steps"]["2026-09-27"] = {"n": 8000, "t": "2026-09-27T21:00:00+02:00"}
|
||||
doc["activity"] = {
|
||||
"sessions": [{"day": "2026-09-27"}],
|
||||
"t": "2026-09-27T21:00:00+02:00",
|
||||
}
|
||||
doc["weights"]["junk"] = "not a cell"
|
||||
back = log_to_body(parse_remote_body(encode_body_for_push(body_to_log(doc))))
|
||||
assert _cell(back["profile"]["sex"])["v"] == "m"
|
||||
assert _cell(back["weights"]["2026-09-27"])["kg"] == 72.6
|
||||
assert _cell(back["bodyfat"]["2026-09-27"])["pct"] == 16.8
|
||||
assert _cell(back["steps"]["2026-09-27"])["n"] == 8000
|
||||
assert back["activity"]["sessions"] == [{"day": "2026-09-27"}]
|
||||
assert "junk" not in back["weights"]
|
||||
|
||||
def test_empty_doc_is_an_empty_log(self) -> None:
|
||||
assert body_to_log(read_body()) == {}
|
||||
assert log_to_body({})["weights"] == {}
|
||||
|
||||
def test_malformed_records_are_dropped(self) -> None:
|
||||
log = body_to_log(
|
||||
{
|
||||
"profile": {},
|
||||
"weights": {"2026-09-01": {"kg": 1, "t": "x"}},
|
||||
"bodyfat": {},
|
||||
"steps": {},
|
||||
"activity": {"sessions": "not a list"},
|
||||
}
|
||||
)
|
||||
record = log["w:2026-09-01"]
|
||||
record.fields["kg"] = ("not a dict", record.fields["kg"][1])
|
||||
log["w:2026-09-02"] = type(record)(id="w:2026-09-02", fields={})
|
||||
log["activity"] = type(record)(
|
||||
id="activity", fields={"sessions": ("x", record.fields["kg"][1])}
|
||||
)
|
||||
back = log_to_body(log)
|
||||
assert back["weights"] == {}
|
||||
assert back["activity"] == {}
|
||||
|
||||
def test_non_object_payload_raises(self) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
parse_remote_body("[1]")
|
||||
|
||||
def test_later_edit_wins_the_merge(self) -> None:
|
||||
set_weight("2026-09-27", 72.0)
|
||||
mine = body_to_log(read_body())
|
||||
doc = read_body()
|
||||
doc["weights"]["2026-09-27"] = {
|
||||
"kg": 99.0,
|
||||
"src": "manual",
|
||||
"t": "2000-01-01T00:00:00+00:00",
|
||||
}
|
||||
merged = merge_logs(mine, body_to_log(doc))
|
||||
assert _cell(log_to_body(merged)["weights"]["2026-09-27"])["kg"] == 72.0
|
||||
|
||||
|
||||
class TestSyncBody:
|
||||
def _client(self, files: dict[str, str]) -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.list_directory.return_value = []
|
||||
client.get_file_text.side_effect = files.get
|
||||
return client
|
||||
|
||||
def test_merges_a_peer_and_pushes(self) -> None:
|
||||
peer_doc = read_body()
|
||||
peer_doc["weights"]["2026-09-20"] = {
|
||||
"kg": 74.0,
|
||||
"src": "manual",
|
||||
"t": "2026-09-20T08:00:00+02:00",
|
||||
}
|
||||
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/{device_id()}/body.json"
|
||||
|
||||
def test_skips_missing_and_unparsable_peers(self) -> None:
|
||||
client = self._client({"diet-guard-sync/devices/bad/body.json": "{oops"})
|
||||
_sync_body.sync_body(client, ["bad", "absent"])
|
||||
client.put_file_text.assert_not_called()
|
||||
|
||||
def test_ingests_phone_weigh_ins_first(self) -> None:
|
||||
client = self._client({})
|
||||
with patch.object(
|
||||
_sync_body,
|
||||
"fetch_alarm_logs",
|
||||
return_value=[("p", _alarm({"date": "2026-09-27", "kg": 72.6}))],
|
||||
):
|
||||
_sync_body.sync_body(client, [])
|
||||
assert weights() == {"2026-09-27": 72.6}
|
||||
client.put_file_text.assert_called_once()
|
||||
|
||||
|
||||
class TestPublishActivity:
|
||||
def test_no_source_publishes_nothing(self) -> None:
|
||||
with patch.object(_sync_body, "set_activity") as publish:
|
||||
_sync_body.publish_activity()
|
||||
publish.assert_not_called()
|
||||
|
||||
def test_publishes_when_a_source_exists(self) -> None:
|
||||
with (
|
||||
patch.object(_sync_body, "sources_available", return_value=True),
|
||||
patch.object(_sync_body, "collect_sessions", return_value=()),
|
||||
patch.object(_sync_body, "set_activity", return_value=True) as publish,
|
||||
):
|
||||
_sync_body.publish_activity()
|
||||
publish.assert_called_once_with(())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user