Fit Body and Food Bank on one phone screen

Body: 2-column tiles, each opens its card in a sheet. Food bank: paged
3-column tiles, tap for macros. Pixel 6a widget test asserts no vertical
scroll on log, calendar, food bank (250 foods) and body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVUTaKFW8m7YrvWbzV
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 08:08:23 +01:00
parent 3960fb8ca0
commit 2a674774bb
14 changed files with 899 additions and 173 deletions

View File

@ -10,76 +10,26 @@ library;
import 'dart:async';
import 'package:diet_guard_app/services/activity_kcal.dart';
import 'package:diet_guard_app/screens/body_screen_data.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_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/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/body_tile_grid.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),
};
export 'package:diet_guard_app/screens/body_screen_data.dart';
/// The Body screen.
class BodyScreen extends StatefulWidget {
@ -100,7 +50,17 @@ class _BodyScreenState extends State<BodyScreen> {
late final bool _profileAtTop = !BodyService.instance.profileComplete;
/// Whether the Health Connect button shows: supported and not yet granted.
bool _canConnectSteps = false;
///
/// A notifier, not a bool: the Activity sheet stays open while the
/// permission is granted, and its button must disappear and its numbers
/// refresh without reopening the sheet.
final ValueNotifier<bool> _canConnectSteps = ValueNotifier(false);
@override
void dispose() {
_canConnectSteps.dispose();
super.dispose();
}
@override
void initState() {
@ -117,7 +77,8 @@ class _BodyScreenState extends State<BodyScreen> {
final granted = await source.hasPermission().catchError((_) => false);
if (granted) await refreshSteps();
if (!mounted) return;
setState(() => _canConnectSteps = !granted);
_canConnectSteps.value = !granted;
setState(() {});
}
Future<void> _connectSteps() async {
@ -126,7 +87,8 @@ class _BodyScreenState extends State<BodyScreen> {
);
if (granted) await refreshSteps();
if (!mounted) return;
setState(() => _canConnectSteps = !granted);
_canConnectSteps.value = !granted;
setState(() {});
}
Future<void> _loadLog() async {
@ -144,14 +106,11 @@ class _BodyScreenState extends State<BodyScreen> {
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);
final (exercise, _, _) = bodyExercise(body, bio, today);
final tiles = _tiles(body, bio, exercise);
return Scaffold(
appBar: AppBar(title: const Text('Body')),
body: SingleChildScrollView(
body: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Center(
child: ConstrainedBox(
@ -159,36 +118,11 @@ class _BodyScreenState extends State<BodyScreen> {
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,
BodyTileGrid(tiles: tiles),
const SizedBox(height: AppSpacing.sm),
BodyNote(
'Age ${bodyAgeText(body, today)} · synced with the PC.',
),
WeekPlanCard(
defaultTarget: AppSettingsService.dailyKcalGoal,
logged: _logged,
),
if (!_profileAtTop) profile,
BodyFatCard(onChanged: _onChanged),
BodyNote('Age ${_age(body, today)} · synced with the PC.'),
],
),
),
@ -196,18 +130,100 @@ class _BodyScreenState extends State<BodyScreen> {
),
);
}
}
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.';
/// The Activity card from the live service state.
Widget _activityCard({required bool canConnect}) {
final body = BodyService.instance;
final (exercise, stepsKcal, window) = bodyExercise(
body,
currentBiometrics(body, DateTime.now()),
DateTime.now(),
);
return ActivityCard(
sessions: body.sessions,
weightKg: body.latestWeight?.$2,
exercise: exercise,
steps: stepsByDay(body),
stepsKcal: stepsKcal,
window: window,
onConnectSteps: canConnect ? _connectSteps : null,
);
}
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)}';
/// Every section as a tile: the profile first while it is incomplete, last
/// otherwise (decided once, see [_profileAtTop]).
List<BodyTileSpec> _tiles(
BodyService body,
Biometrics? bio,
Map<String, double>? exercise,
) {
final weight = body.latestWeight?.$2;
final bodyFat = body.latestBodyFat?.$2;
final profile = BodyTileSpec(
title: 'Profile',
builder: (_) => ProfileCard(onSaved: _onChanged),
);
final goal = bio == null
? null
: goalTarget(
savedGoal(body),
tableBmr(bio, bodyFat).value!,
tableBmr(bio, bodyFat).name,
exercise,
).kcal;
return [
if (_profileAtTop) profile,
BodyTileSpec(
title: 'Daily calorie target',
value: goal == null ? null : '$goal kcal',
builder: (_) => bio == null
? BodySection(
title: 'Daily calorie target',
children: [BodyNote(bodyMissingNote(body))],
)
: GoalCard(bio: bio, exercise: exercise, bodyFatPct: bodyFat),
),
BodyTileSpec(
title: 'Weight',
value: weight == null ? null : formatKg(weight),
builder: (_) => WeightCard(onChanged: _onChanged),
),
if (bio != null) ...[
BodyTileSpec(
title: 'BMI',
value: oneDecimal(bmiReport(bio).bmi),
builder: (_) => BmiCard(bio: bio),
),
BodyTileSpec(
title: 'Ideal weight',
builder: (_) => IdealWeightCard(bio: bio),
),
BodyTileSpec(
title: 'Resting metabolism (BMR)',
value: '${whole(tableBmr(bio, bodyFat).value!)} kcal',
builder: (_) => BmrCard(bio: bio, bodyFatPct: bodyFat),
),
],
BodyTileSpec(
title: 'Activity',
builder: (_) => ValueListenableBuilder<bool>(
valueListenable: _canConnectSteps,
builder: (_, canConnect, _) => _activityCard(canConnect: canConnect),
),
),
BodyTileSpec(
title: 'Week planner (info only)',
builder: (_) => WeekPlanCard(
defaultTarget: AppSettingsService.dailyKcalGoal,
logged: _logged,
),
),
if (!_profileAtTop) profile,
BodyTileSpec(
title: 'Body fat',
value: bodyFat == null ? null : '${oneDecimal(bodyFat)} %',
builder: (_) => BodyFatCard(onChanged: _onChanged),
),
];
}
}

View File

@ -0,0 +1,78 @@
/// Pure data helpers behind the Body screen (split out for the 250-line cap).
library;
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_service.dart';
import 'package:diet_guard_app/services/body_steps.dart';
import 'package:diet_guard_app/services/log_storage_service.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),
};
/// Why the numbers are missing.
String bodyMissingNote(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.';
}
/// The age line value.
String bodyAgeText(BodyService body, DateTime today) {
final birth = DateTime.tryParse(body.birth ?? '');
return birth == null ? 'unknown' : '${ageOn(birth, today)}';
}

View File

@ -13,6 +13,8 @@ import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_add_entry_dialog.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_record_tile.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_tile_grid.dart';
import 'package:flutter/material.dart';
/// Browses every banked food -- log-derived and manually curated -- with
@ -129,6 +131,13 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
);
}
/// Shows the full macro line for a tapped tile.
Future<void> _openDetail(FoodBankRecord record) => showModalBottomSheet<void>(
context: context,
useSafeArea: true,
builder: (_) => SafeArea(child: RecordTile(record)),
);
Future<void> _openAddDialog() async {
final result = await showDialog<FoodBankRecord>(
context: context,
@ -184,10 +193,7 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
textAlign: TextAlign.center,
),
)
: ListView.builder(
itemCount: _displayed.length,
itemBuilder: (context, i) => RecordTile(_displayed[i]),
),
: FbTileGrid(records: _displayed, onOpen: _openDetail),
floatingActionButton: FloatingActionButton(
onPressed: _openAddDialog,
tooltip: 'Add manual entry',
@ -196,43 +202,3 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
);
}
}
// ---------------------------------------------------------------------------
// Record tile
// ---------------------------------------------------------------------------
/// One banked food: its name, macros, and how often it has been logged.
class RecordTile extends StatelessWidget {
/// Creates a [RecordTile] for [record].
const RecordTile(this.record, {super.key});
/// The banked record this tile renders.
final FoodBankRecord record;
@override
Widget build(BuildContext context) {
final macros =
'P ${record.proteinG.toStringAsFixed(0)} g '
'C ${record.carbsG.toStringAsFixed(0)} g '
'F ${record.fatG.toStringAsFixed(0)} g';
final per = record.grams > 0
? ' per ${record.grams.toStringAsFixed(0)} g'
: '';
return ListTile(
title: Text(record.desc),
subtitle: Text(
'${record.kcal.toStringAsFixed(0)} kcal$per · $macros',
),
trailing: record.count > 0
? Text(
'×${record.count.toStringAsFixed(0)}',
style: Theme.of(context).textTheme.bodySmall,
)
: null,
);
}
}
// ---------------------------------------------------------------------------
// Filter sheet
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,111 @@
/// The Body screen's overview: two-column metric tiles that fit one phone
/// screen. Tapping a tile opens the section's full card in a bottom sheet.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// One tile: a section [title], an optional headline [value], and the
/// [builder] of the full card shown in the sheet.
class BodyTileSpec {
/// Creates a tile spec.
const BodyTileSpec({required this.title, required this.builder, this.value});
/// Section name, the same text the full card carries.
final String title;
/// Headline number for the tile (null when the section has none).
final String? value;
/// Builds the full card for the sheet.
final WidgetBuilder builder;
}
/// Height of one tile row (dp).
const double bodyTileHeight = 84;
/// Lays [tiles] out two per row, in order.
class BodyTileGrid extends StatelessWidget {
/// Creates the grid.
const BodyTileGrid({required this.tiles, super.key});
/// Tiles in display order.
final List<BodyTileSpec> tiles;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < tiles.length; i += 2) ...[
if (i > 0) const SizedBox(height: AppSpacing.sm),
SizedBox(
height: bodyTileHeight,
child: Row(
children: [
Expanded(child: _Tile(tiles[i])),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: i + 1 < tiles.length
? _Tile(tiles[i + 1])
: const SizedBox.shrink(),
),
],
),
),
],
],
);
}
}
class _Tile extends StatelessWidget {
const _Tile(this.spec);
final BodyTileSpec spec;
Future<void> _open(BuildContext context) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheet) => SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.md),
child: spec.builder(sheet),
),
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: InkWell(
onTap: () => _open(context),
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
spec.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
if (spec.value != null)
Text(
spec.value!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,35 @@
/// The full-detail row for one banked food, shown in the tile's sheet.
library;
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:flutter/material.dart';
/// One banked food: its name, macros, and how often it has been logged.
class RecordTile extends StatelessWidget {
/// Creates a [RecordTile] for [record].
const RecordTile(this.record, {super.key});
/// The banked record this tile renders.
final FoodBankRecord record;
@override
Widget build(BuildContext context) {
final macros =
'P ${record.proteinG.toStringAsFixed(0)} g '
'C ${record.carbsG.toStringAsFixed(0)} g '
'F ${record.fatG.toStringAsFixed(0)} g';
final per = record.grams > 0
? ' per ${record.grams.toStringAsFixed(0)} g'
: '';
return ListTile(
title: Text(record.desc),
subtitle: Text('${record.kcal.toStringAsFixed(0)} kcal$per · $macros'),
trailing: record.count > 0
? Text(
'×${record.count.toStringAsFixed(0)}',
style: Theme.of(context).textTheme.bodySmall,
)
: null,
);
}
}

View File

@ -0,0 +1,214 @@
/// Paged 3-column tile grid for the food bank: a whole page of foods fits one
/// phone screen with no vertical scrolling, and further pages are a horizontal
/// swipe away. Tapping a tile opens the full macro line in a sheet.
library;
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// Tiles per row.
const int fbGridColumns = 3;
/// Fixed tile height (dp); the page's row count is derived from it.
const double fbTileHeight = 76;
/// Bottom strip kept clear for the page dots and the floating add button.
const double fbBottomReserve = 72;
/// How many rows of [fbTileHeight] fit in [height] after the reserve.
int fbRowsFor(double height) {
final usable = height - fbBottomReserve + AppSpacing.sm;
final rows = (usable / (fbTileHeight + AppSpacing.sm)).floor();
return rows < 1 ? 1 : rows;
}
/// One banked food as a tile: its name, kcal and use count.
class FbTile extends StatelessWidget {
/// Creates a tile for [record].
const FbTile(this.record, {required this.onTap, super.key});
/// The banked food.
final FoodBankRecord record;
/// Opens the detail sheet.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: InkWell(
onTap: onTap,
onLongPress: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
record.desc,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
),
Row(
children: [
Expanded(
child: Text(
'${record.kcal.toStringAsFixed(0)} kcal',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
),
if (record.count > 0)
Text(
'×${record.count.toStringAsFixed(0)}',
style: theme.textTheme.bodySmall,
),
],
),
],
),
),
),
);
}
}
/// Swipeable pages of [FbTile]s sized to the space the parent gives it.
class FbTileGrid extends StatefulWidget {
/// Creates the grid; [onOpen] receives the tapped record.
const FbTileGrid({required this.records, required this.onOpen, super.key});
/// Everything to page through, in display order.
final List<FoodBankRecord> records;
/// Called when a tile is tapped.
final ValueChanged<FoodBankRecord> onOpen;
@override
State<FbTileGrid> createState() => _FbTileGridState();
}
class _FbTileGridState extends State<FbTileGrid> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _row(List<FoodBankRecord> cells) {
return SizedBox(
height: fbTileHeight,
child: Row(
children: [
for (var c = 0; c < fbGridColumns; c++) ...[
if (c > 0) const SizedBox(width: AppSpacing.sm),
Expanded(
child: c < cells.length
? FbTile(cells[c], onTap: () => widget.onOpen(cells[c]))
: const SizedBox.shrink(),
),
],
],
),
);
}
Widget _pageOf(List<FoodBankRecord> page) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Column(
children: [
for (var i = 0; i < page.length; i += fbGridColumns) ...[
if (i > 0) const SizedBox(height: AppSpacing.sm),
_row(page.sublist(i, (i + fbGridColumns).clamp(0, page.length))),
],
],
),
);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) {
final perPage = fbRowsFor(box.maxHeight) * fbGridColumns;
final total = widget.records.length;
final pages = (total / perPage).ceil();
final current = _page.clamp(0, pages - 1);
return Column(
children: [
const SizedBox(height: AppSpacing.sm),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: pages,
onPageChanged: (p) => setState(() => _page = p),
itemBuilder: (context, p) => Align(
alignment: Alignment.topCenter,
child: _pageOf(
widget.records.sublist(
p * perPage,
((p + 1) * perPage).clamp(0, total),
),
),
),
),
),
SizedBox(
height: fbBottomReserve - AppSpacing.sm,
child: Center(
child: _Dots(count: pages, current: current),
),
),
],
);
},
);
}
}
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.current});
final int count;
final int current;
@override
Widget build(BuildContext context) {
if (count < 2) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
// Long banks would overflow a row of dots: show a sliding window of 7.
final start = (current - 3).clamp(0, (count - 7).clamp(0, count));
final end = (start + 7).clamp(0, count);
return ExcludeSemantics(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = start; i < end; i++)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.25),
),
),
],
),
);
}
}

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.126+1
version: 1.0.127+1
environment:
sdk: ^3.12.2

View File

@ -21,6 +21,7 @@ void main() {
) async {
await seedPerson(store);
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
Future<void> choose(String word, String option) async {
await tester.tap(find.text(word).first);
await tester.pumpAndSettle();
@ -84,6 +85,7 @@ void main() {
await body.setWeight('2026-09-20', 80);
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('no workouts published yet'), findsOneWidget);
await tester.tap(find.text('Measured (MET)'));
await tester.pumpAndSettle();

View File

@ -34,6 +34,7 @@ void main() {
stepsSource = fake;
await pumpBody(tester);
expect(fake.lastRead, isNull, reason: 'never reads before permission');
await openTile(tester, 'Activity');
await tester.tap(find.text('Connect Health Connect steps'));
await tester.pumpAndSettle();
@ -53,6 +54,7 @@ void main() {
final fake = FakeStepsSource();
stepsSource = fake;
await pumpBody(tester);
await openTile(tester, 'Activity');
expect(fake.lastRead, isNotNull);
expect(find.text('Connect Health Connect steps'), findsNothing);
});
@ -60,6 +62,7 @@ void main() {
testWidgets('a Health Connect error just hides nothing', (tester) async {
stepsSource = FakeStepsSource()..failWith = StateError('gone');
await pumpBody(tester);
await openTile(tester, 'Activity');
expect(find.text('Connect Health Connect steps'), findsOneWidget);
});
}

View File

@ -7,6 +7,7 @@ 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:diet_guard_app/widgets/body/body_tile_grid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -87,3 +88,30 @@ Future<void> seedPerson(MemoryDocStore store, {Object? goal}) async {
/// The top edge of [finder]'s first match.
double topOf(WidgetTester tester, Finder finder) =>
tester.getTopLeft(finder.first).dy;
/// The overview tile titled [title].
Finder tile(String title) =>
find.descendant(of: find.byType(BodyTileGrid), matching: find.text(title));
/// Opens the sheet behind the tile titled [title].
Future<void> openTile(WidgetTester tester, String title) async {
await tester.tap(tile(title));
await tester.pumpAndSettle();
}
/// Dismisses the open sheet by tapping its scrim.
Future<void> closeSheet(WidgetTester tester) async {
await tester.tapAt(const Offset(5, 5));
await tester.pumpAndSettle();
}
/// [finder] restricted to the open sheet (the tiles stay mounted behind it).
Finder inSheet(Finder finder) =>
find.descendant(of: find.byType(BottomSheet), matching: finder);
/// Titles of the overview tiles, in display order.
List<String> tileTitles(WidgetTester tester) => tester
.widget<BodyTileGrid>(find.byType(BodyTileGrid))
.tiles
.map((t) => t.title)
.toList();

View File

@ -1,7 +1,6 @@
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';
@ -16,18 +15,23 @@ void main() {
});
tearDown(tearDownBodyServices);
testWidgets('with nothing set the profile is on top and asks for data', (
testWidgets('with nothing set the profile is first 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(tileTitles(tester).first, 'Profile');
expect(find.textContaining('Age unknown'), findsOneWidget);
expect(
topOf(tester, find.byType(ProfileCard)),
lessThan(topOf(tester, find.text('Daily calorie target'))),
);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('Set your profile and enter'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Activity');
expect(find.textContaining('No activity data yet'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Weight');
expect(find.text('Need two readings for a graph.'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Body fat');
expect(find.text('Need two readings for a graph.'), findsOneWidget);
});
testWidgets('a weight without a profile asks for the profile', (
@ -35,6 +39,7 @@ void main() {
) async {
await BodyService.instance.setWeight('2026-09-20', 80);
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('Set your profile (birth'), findsOneWidget);
});
@ -45,6 +50,7 @@ void main() {
sex: 'f',
);
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
expect(find.text('Enter your weight to see the numbers.'), findsOneWidget);
});
@ -53,29 +59,44 @@ void main() {
) 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));
final titles = tileTitles(tester);
expect(titles.first, 'Daily calorie target');
expect(titles.indexOf('Weight'), 1);
expect(
profileTop,
greaterThan(topOf(tester, find.text('Week planner (info only)'))),
titles.indexOf('Profile'),
greaterThan(titles.indexOf('Week planner (info only)')),
);
expect(topOf(tester, find.text('Body fat')), greaterThan(profileTop));
expect(titles.last, 'Body fat');
// Headline numbers on the tiles themselves.
expect(find.text('2209 kcal'), findsOneWidget);
expect(find.text('80.0 kg'), findsOneWidget);
expect(find.text('24.7'), findsOneWidget);
expect(find.textContaining('Age 30'), findsOneWidget);
// 80 kg, 180 cm, 30 y, male, lose 0.5 kg/week on Moderate:
// 1780 x 1.55 - 550 = 2209.
await openTile(tester, 'Daily calorie target');
expect(find.text('2209'), findsOneWidget);
expect(
find.textContaining('Maintenance 2759 kcal (Mifflin-St Jeor'),
findsOneWidget,
);
expect(find.text('24.7'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'BMI');
expect(inSheet(find.text('24.7')), findsOneWidget);
expect(find.text('59.9-80.7 kg'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Ideal weight');
expect(find.text('Average of all'), findsOneWidget);
expect(find.textContaining('Broca'), findsNothing);
expect(find.text('1780 kcal'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Resting metabolism (BMR)');
expect(inSheet(find.text('1780 kcal')), findsOneWidget);
expect(find.text('n/a -- log a body-fat %'), findsNWidgets(2));
await closeSheet(tester);
// 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.
await openTile(tester, 'Activity');
expect(
find.textContaining('Steps outside workouts: 9000 on ${yesterdayKey()}'),
findsOneWidget,
@ -84,7 +105,6 @@ void main() {
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);
});
@ -93,19 +113,25 @@ void main() {
) async {
await seedPerson(store);
await pumpBody(tester);
await openTile(tester, 'Body fat');
final field = find.widgetWithText(TextField, 'Body fat (%)');
await tester.enterText(field, '99');
await tester.tap(find.text('Save').at(1));
await tester.tap(find.text('Save'));
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.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(BodyService.instance.latestBodyFat?.$2, 18.5);
await closeSheet(tester);
expect(find.text('18.5 %'), findsOneWidget);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('(Katch-McArdle resting'), findsOneWidget);
expect(find.text('1778 kcal'), findsOneWidget);
expect(find.text('2206'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Resting metabolism (BMR)');
expect(inSheet(find.text('1778 kcal')), findsOneWidget);
});
testWidgets('saving and deleting a weight updates the numbers', (
@ -113,19 +139,22 @@ void main() {
) async {
await seedPerson(store);
await pumpBody(tester);
await openTile(tester, 'Weight');
final field = find.widgetWithText(TextField, 'Weight (kg)');
await tester.enterText(field, '5');
await tester.tap(find.text('Save').first);
await tester.tap(find.text('Save'));
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.tap(find.text('Save'));
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 closeSheet(tester);
expect(find.text('27.9'), findsOneWidget); // 90.5 / 1.8^2 on the BMI tile
await openTile(tester, 'Weight');
await tester.tap(find.byTooltip('Delete this day').first);
await tester.pumpAndSettle();
expect(BodyService.instance.latestWeight?.$1, yesterdayKey());
@ -133,6 +162,7 @@ void main() {
testWidgets('the profile form validates height and saves', (tester) async {
await pumpBody(tester);
await openTile(tester, 'Profile');
final field = find.widgetWithText(TextField, 'Height (cm)');
await tester.enterText(field, '9');
await tester.tap(find.text('Save profile'));

View File

@ -0,0 +1,133 @@
// One-screen layout gate: every converted main screen fits a Pixel 6a with
// the status bar and nav bar insets and NO vertical scrolling.
//
// Overflow errors are ignored on purpose: flutter_test renders the Ahem font
// (every glyph 1em wide), which is far wider than the real phone font, so a
// text Row "overflows" here that fits on the device. Scroll extents and
// hit-testing are what this file asserts.
import 'dart:io';
import 'package:diet_guard_app/models/food_bank_record.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/log_meal_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/widgets/today_progress_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../body_screen_support.dart';
import 'fit_support.dart';
Future<void> _pump(WidgetTester tester, Widget screen) async {
usePixel6a(tester);
final previous = FlutterError.onError;
FlutterError.onError = (details) {
if (!details.toString().contains('overflowed')) previous!(details);
};
addTearDown(() => FlutterError.onError = previous);
await tester.runAsync(() async {
await tester.pumpWidget(MaterialApp(home: screen));
await Future<void>.delayed(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 300));
});
}
void main() {
late Directory dir;
late MemoryDocStore store;
setUp(() async {
dir = await Directory.systemTemp.createTemp('diet_guard_fit_');
store = MemoryDocStore();
LogStorageService.resetForTesting(store: FileDocumentStore(dir));
FoodBankService.resetForTesting(store: FileDocumentStore(dir));
AppSettingsService.resetForTesting(store: FileDocumentStore(dir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(dir));
await MealScheduleService.initForTesting(FileDocumentStore(dir));
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
MealScheduleService.resetForTesting();
await dir.delete(recursive: true);
});
testWidgets('log meal screen fits', (tester) async {
await _pump(tester, const LogMealScreen());
expect(maxVerticalExtent(tester), 0);
});
testWidgets('log meal screen still fits once the progress card shows', (
tester,
) async {
await _pump(tester, const LogMealScreen());
await tester.enterText(find.byType(TextField).first, 'Oats');
await tester.runAsync(() async {
await tester.tap(find.byTooltip('Log meal'));
await Future<void>.delayed(const Duration(milliseconds: 500));
});
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(TodayProgressCard), findsOneWidget);
expect(maxVerticalExtent(tester), 0);
});
testWidgets('calendar screen fits', (tester) async {
await _pump(tester, const CalendarScreen());
expect(maxVerticalExtent(tester), 0);
});
testWidgets('food bank fits with 250 foods and pages through them', (
tester,
) async {
await tester.runAsync(
() => FoodBankService.instance.writeBank({
for (var i = 0; i < 250; i++)
'food $i': FoodBankRecord(
desc: 'Food ${i.toString().padLeft(3, '0')}',
kcal: 100.0 + i,
proteinG: 1,
carbsG: 2,
fatG: 3,
grams: 100,
count: 250.0 - i,
),
}),
);
await _pump(tester, const FoodBankScreen());
expect(maxVerticalExtent(tester), 0);
final first = find.text('Food 000');
expect(first, findsOneWidget);
final box = tester.getRect(first);
expect(box.bottom, lessThan(2400 / 2.625 - 24 - 72));
// A tile is hit-testable, and long-press/tap opens its detail sheet.
await tester.tap(first);
await tester.pumpAndSettle();
expect(find.textContaining('P 1 g'), findsOneWidget);
await tester.tapAt(const Offset(200, 50)); // scrim closes the sheet
await tester.pumpAndSettle();
// Swiping to the next page reveals foods not on the first page.
expect(find.text('Food 249'), findsNothing);
await tester.fling(find.byType(PageView), const Offset(-600, 0), 2000);
await tester.pumpAndSettle();
expect(first, findsNothing);
});
testWidgets('body screen fits with a full profile', (tester) async {
LogStorageService.resetForTesting(store: store);
AppSettingsService.resetForTesting(store: store);
await tester.runAsync(() => seedPerson(store));
await _pump(tester, const BodyScreen());
expect(maxVerticalExtent(tester), 0);
});
}

View File

@ -0,0 +1,26 @@
// Shared Pixel 6a geometry helpers for the one-screen (no-scroll) tests.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Pixel 6a: 1080x2400 px at 2.625 dpr, 132 px status bar, 63 px nav bar.
void usePixel6a(WidgetTester tester) {
tester.view
..physicalSize = const Size(1080, 2400)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 132, bottom: 63)
..viewPadding = const FakeViewPadding(top: 132, bottom: 63);
addTearDown(tester.view.reset);
}
/// Largest `maxScrollExtent` among every vertical [Scrollable] on screen.
double maxVerticalExtent(WidgetTester tester) {
var worst = 0.0;
for (final element in find.byType(Scrollable).evaluate()) {
final state = (element as StatefulElement).state as ScrollableState;
if (state.widget.axis != Axis.vertical) continue;
final extent = state.position.maxScrollExtent;
if (extent > worst) worst = extent;
}
return worst;
}

View File

@ -0,0 +1,84 @@
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_tile_grid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
List<FoodBankRecord> _foods(int n, {double count = 0}) => [
for (var i = 0; i < n; i++)
FoodBankRecord(
desc: 'Food $i',
kcal: 100,
proteinG: 1,
carbsG: 2,
fatG: 3,
grams: 100,
count: count,
),
];
Future<void> _pump(
WidgetTester tester,
List<FoodBankRecord> records, {
ValueChanged<FoodBankRecord>? onOpen,
}) => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FbTileGrid(records: records, onOpen: onOpen ?? (_) {}),
),
),
);
void main() {
test('rows never drop below one, even in a tiny box', () {
expect(fbRowsFor(10), 1);
expect(fbRowsFor(600), greaterThan(3));
});
testWidgets('a short list is one page: partial row, no dots, no count', (
tester,
) async {
FoodBankRecord? opened;
await _pump(tester, _foods(4), onOpen: (r) => opened = r);
expect(find.text('Food 3'), findsOneWidget);
expect(find.textContaining('×'), findsNothing);
expect(find.byType(PageView), findsOneWidget);
await tester.tap(find.text('Food 3'));
expect(opened?.desc, 'Food 3');
});
testWidgets('used foods show their count', (tester) async {
await _pump(tester, _foods(2, count: 7));
expect(find.text('×7'), findsNWidgets(2));
});
testWidgets('many pages show a sliding window of dots', (tester) async {
await _pump(tester, _foods(400));
final dots = find.descendant(
of: find.byType(ExcludeSemantics),
matching: find.byType(Container),
);
expect(dots, findsNWidgets(7));
await tester.fling(find.byType(PageView), const Offset(-600, 0), 3000);
await tester.pumpAndSettle();
await tester.fling(find.byType(PageView), const Offset(-600, 0), 3000);
await tester.pumpAndSettle();
await tester.fling(find.byType(PageView), const Offset(-600, 0), 3000);
await tester.pumpAndSettle();
await tester.fling(find.byType(PageView), const Offset(-600, 0), 3000);
await tester.pumpAndSettle();
expect(dots, findsNWidgets(7));
});
testWidgets('shrinking the list while on a late page stays in range', (
tester,
) async {
await _pump(tester, _foods(400));
for (var i = 0; i < 3; i++) {
await tester.fling(find.byType(PageView), const Offset(-600, 0), 3000);
await tester.pumpAndSettle();
}
await _pump(tester, _foods(5));
await tester.pumpAndSettle();
expect(find.text('Food 0'), findsOneWidget);
});
}