From 2a674774bb0d300bb91153842735b5ef5c2196e4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 08:08:23 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WzxdWVUTaKFW8m7YrvWbzV --- app/lib/screens/body_screen.dart | 224 ++++++++++-------- app/lib/screens/body_screen_data.dart | 78 ++++++ app/lib/screens/food_bank_screen.dart | 54 +---- app/lib/widgets/body/body_tile_grid.dart | 111 +++++++++ app/lib/widgets/food_bank/fb_record_tile.dart | 35 +++ app/lib/widgets/food_bank/fb_tile_grid.dart | 214 +++++++++++++++++ app/pubspec.yaml | 2 +- app/test/screens/body_screen_goal_test.dart | 2 + app/test/screens/body_screen_steps_test.dart | 3 + app/test/screens/body_screen_support.dart | 28 +++ app/test/screens/body_screen_test.dart | 78 ++++-- app/test/screens/fit/fit_screens_test.dart | 133 +++++++++++ app/test/screens/fit/fit_support.dart | 26 ++ .../widgets/food_bank/fb_tile_grid_test.dart | 84 +++++++ 14 files changed, 899 insertions(+), 173 deletions(-) create mode 100644 app/lib/screens/body_screen_data.dart create mode 100644 app/lib/widgets/body/body_tile_grid.dart create mode 100644 app/lib/widgets/food_bank/fb_record_tile.dart create mode 100644 app/lib/widgets/food_bank/fb_tile_grid.dart create mode 100644 app/test/screens/fit/fit_screens_test.dart create mode 100644 app/test/screens/fit/fit_support.dart create mode 100644 app/test/widgets/food_bank/fb_tile_grid_test.dart diff --git a/app/lib/screens/body_screen.dart b/app/lib/screens/body_screen.dart index 7b7f01d..29b5a7e 100644 --- a/app/lib/screens/body_screen.dart +++ b/app/lib/screens/body_screen.dart @@ -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 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?, 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 []; - return ( - dailyExercise(all, weight, today, byDay), - extraShare(all, today, byDay), - windowDays(all, today, byDay), - ); -} - -/// Sums each day's non-deleted kcal. -Map kcalByDay(DayLog log) => { - for (final entry in log.entries) - entry.key: entry.value - .where((food) => !food.deleted) - .fold(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 { 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 _canConnectSteps = ValueNotifier(false); + + @override + void dispose() { + _canConnectSteps.dispose(); + super.dispose(); + } @override void initState() { @@ -117,7 +77,8 @@ class _BodyScreenState extends State { final granted = await source.hasPermission().catchError((_) => false); if (granted) await refreshSteps(); if (!mounted) return; - setState(() => _canConnectSteps = !granted); + _canConnectSteps.value = !granted; + setState(() {}); } Future _connectSteps() async { @@ -126,7 +87,8 @@ class _BodyScreenState extends State { ); if (granted) await refreshSteps(); if (!mounted) return; - setState(() => _canConnectSteps = !granted); + _canConnectSteps.value = !granted; + setState(() {}); } Future _loadLog() async { @@ -144,14 +106,11 @@ class _BodyScreenState extends State { 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 { 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 { ), ); } -} -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 _tiles( + BodyService body, + Biometrics? bio, + Map? 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( + 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), + ), + ]; + } } diff --git a/app/lib/screens/body_screen_data.dart b/app/lib/screens/body_screen_data.dart new file mode 100644 index 0000000..f32735d --- /dev/null +++ b/app/lib/screens/body_screen_data.dart @@ -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 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?, 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 []; + return ( + dailyExercise(all, weight, today, byDay), + extraShare(all, today, byDay), + windowDays(all, today, byDay), + ); +} + +/// Sums each day's non-deleted kcal. +Map kcalByDay(DayLog log) => { + for (final entry in log.entries) + entry.key: entry.value + .where((food) => !food.deleted) + .fold(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)}'; +} diff --git a/app/lib/screens/food_bank_screen.dart b/app/lib/screens/food_bank_screen.dart index a184f60..672a0e5 100644 --- a/app/lib/screens/food_bank_screen.dart +++ b/app/lib/screens/food_bank_screen.dart @@ -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 { ); } + /// Shows the full macro line for a tapped tile. + Future _openDetail(FoodBankRecord record) => showModalBottomSheet( + context: context, + useSafeArea: true, + builder: (_) => SafeArea(child: RecordTile(record)), + ); + Future _openAddDialog() async { final result = await showDialog( context: context, @@ -184,10 +193,7 @@ class _FoodBankScreenState extends State { 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 { ); } } - -// --------------------------------------------------------------------------- -// 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 -// --------------------------------------------------------------------------- diff --git a/app/lib/widgets/body/body_tile_grid.dart b/app/lib/widgets/body/body_tile_grid.dart new file mode 100644 index 0000000..890e197 --- /dev/null +++ b/app/lib/widgets/body/body_tile_grid.dart @@ -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 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 _open(BuildContext context) => showModalBottomSheet( + 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/food_bank/fb_record_tile.dart b/app/lib/widgets/food_bank/fb_record_tile.dart new file mode 100644 index 0000000..07ca543 --- /dev/null +++ b/app/lib/widgets/food_bank/fb_record_tile.dart @@ -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, + ); + } +} diff --git a/app/lib/widgets/food_bank/fb_tile_grid.dart b/app/lib/widgets/food_bank/fb_tile_grid.dart new file mode 100644 index 0000000..0dc34dd --- /dev/null +++ b/app/lib/widgets/food_bank/fb_tile_grid.dart @@ -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 records; + + /// Called when a tile is tapped. + final ValueChanged onOpen; + + @override + State createState() => _FbTileGridState(); +} + +class _FbTileGridState extends State { + final _controller = PageController(); + int _page = 0; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _row(List 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 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), + ), + ), + ], + ), + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index fbabe08..77ceb18 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/screens/body_screen_goal_test.dart b/app/test/screens/body_screen_goal_test.dart index e042ce0..34c0075 100644 --- a/app/test/screens/body_screen_goal_test.dart +++ b/app/test/screens/body_screen_goal_test.dart @@ -21,6 +21,7 @@ void main() { ) async { await seedPerson(store); await pumpBody(tester); + await openTile(tester, 'Daily calorie target'); Future 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(); diff --git a/app/test/screens/body_screen_steps_test.dart b/app/test/screens/body_screen_steps_test.dart index 3ce1d95..f9937ee 100644 --- a/app/test/screens/body_screen_steps_test.dart +++ b/app/test/screens/body_screen_steps_test.dart @@ -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); }); } diff --git a/app/test/screens/body_screen_support.dart b/app/test/screens/body_screen_support.dart index 2bf2e1a..e82870a 100644 --- a/app/test/screens/body_screen_support.dart +++ b/app/test/screens/body_screen_support.dart @@ -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 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 openTile(WidgetTester tester, String title) async { + await tester.tap(tile(title)); + await tester.pumpAndSettle(); +} + +/// Dismisses the open sheet by tapping its scrim. +Future 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 tileTitles(WidgetTester tester) => tester + .widget(find.byType(BodyTileGrid)) + .tiles + .map((t) => t.title) + .toList(); diff --git a/app/test/screens/body_screen_test.dart b/app/test/screens/body_screen_test.dart index e6dae8c..bbe2eab 100644 --- a/app/test/screens/body_screen_test.dart +++ b/app/test/screens/body_screen_test.dart @@ -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')); diff --git a/app/test/screens/fit/fit_screens_test.dart b/app/test/screens/fit/fit_screens_test.dart new file mode 100644 index 0000000..564fe50 --- /dev/null +++ b/app/test/screens/fit/fit_screens_test.dart @@ -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 _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.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.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); + }); +} diff --git a/app/test/screens/fit/fit_support.dart b/app/test/screens/fit/fit_support.dart new file mode 100644 index 0000000..541b4c2 --- /dev/null +++ b/app/test/screens/fit/fit_support.dart @@ -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; +} diff --git a/app/test/widgets/food_bank/fb_tile_grid_test.dart b/app/test/widgets/food_bank/fb_tile_grid_test.dart new file mode 100644 index 0000000..ec5300f --- /dev/null +++ b/app/test/widgets/food_bank/fb_tile_grid_test.dart @@ -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 _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 _pump( + WidgetTester tester, + List records, { + ValueChanged? 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); + }); +}