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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-28 09:03:03 +02:00
parent 4b1de83e27
commit e761926df4
112 changed files with 12904 additions and 327 deletions

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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()

View File

@ -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

View 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},
};
}

View 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);
}

View 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;

View 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)}';
}

View File

@ -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',

View File

@ -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.

View File

@ -190,6 +190,7 @@ class _LogMealScreenState extends State<LogMealScreen>
onHistory: onOpenHistory,
onCalendar: onOpenCalendar,
onSettings: onOpenSettings,
onBody: onOpenBody,
),
],
),

View File

@ -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 {

View 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;
}

View File

@ -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();

View 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;
}

View 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,
);
}

View 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)});

View 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)];
}
}

View 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;
}

View 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;
}
}

View 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';

View 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
}

View 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 => [];
}

View 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));
}

View 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(),
});

View File

@ -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;
}

View 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;
}
}

View 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,
);
}

View 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 ? '~' : ''}';

View 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();

View 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',
),
],
);
}
}

View 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.',
),
],
);
}
}

View 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')),
],
);
}
}

View 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')),
],
),
],
);
}
}

View 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';
}

View 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),
],
);
}
}

View 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)}';
}

View File

@ -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:

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.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

View 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);
});
}

View 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);
});
}

View 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;

View 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);
});
}

View File

@ -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()));

View File

@ -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);

View 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));
});
});
}

View File

@ -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 {

View 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']);
}
}
});
}

View 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');
});
}

View 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'));
},
);
}

View 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() {}
}

View File

@ -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 {

View 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'});
});
}

View 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});
});
}

View 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);
});
}

View 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);
});
});
}

View 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);
});
}

View 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)

View 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
)

View 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
View 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
View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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)
),
)

View File

@ -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),
}

View File

@ -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
View 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

View File

@ -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

View File

@ -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

View 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}.")

View 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)

View 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),
}
)

View 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

View 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)

View File

@ -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,

View 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)

View File

@ -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}).")

View File

@ -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
View 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",
)

View File

@ -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
View 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,
)

View 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,
)

View File

@ -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

View 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,
),
]

View File

@ -164,6 +164,7 @@ _FAKE_TK = SimpleNamespace(
Button=FakeWidget,
OptionMenu=FakeWidget,
Radiobutton=FakeRadiobutton,
Checkbutton=FakeWidget,
Entry=FakeEntry,
Text=FakeText,
Listbox=FakeListbox,

View File

@ -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

View File

@ -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)

View 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()

View 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

View 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"])

View 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()

View 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}

View 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