mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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:
parent
3960fb8ca0
commit
2a674774bb
@ -10,76 +10,26 @@ library;
|
|||||||
|
|
||||||
import 'dart:async';
|
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/app_settings_service.dart';
|
||||||
import 'package:diet_guard_app/services/body_calc.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_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/health_steps.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||||
import 'package:diet_guard_app/ui/theme.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/activity_card.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/body_section.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_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/goal_card.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/profile_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/week_plan_card.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/weight_card.dart';
|
import 'package:diet_guard_app/widgets/body/weight_card.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Builds the person from the stored profile and newest weight, or null
|
export 'package:diet_guard_app/screens/body_screen_data.dart';
|
||||||
/// 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.
|
/// The Body screen.
|
||||||
class BodyScreen extends StatefulWidget {
|
class BodyScreen extends StatefulWidget {
|
||||||
@ -100,7 +50,17 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
late final bool _profileAtTop = !BodyService.instance.profileComplete;
|
late final bool _profileAtTop = !BodyService.instance.profileComplete;
|
||||||
|
|
||||||
/// Whether the Health Connect button shows: supported and not yet granted.
|
/// 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -117,7 +77,8 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
final granted = await source.hasPermission().catchError((_) => false);
|
final granted = await source.hasPermission().catchError((_) => false);
|
||||||
if (granted) await refreshSteps();
|
if (granted) await refreshSteps();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _canConnectSteps = !granted);
|
_canConnectSteps.value = !granted;
|
||||||
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connectSteps() async {
|
Future<void> _connectSteps() async {
|
||||||
@ -126,7 +87,8 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
);
|
);
|
||||||
if (granted) await refreshSteps();
|
if (granted) await refreshSteps();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _canConnectSteps = !granted);
|
_canConnectSteps.value = !granted;
|
||||||
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLog() async {
|
Future<void> _loadLog() async {
|
||||||
@ -144,14 +106,11 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
final body = BodyService.instance;
|
final body = BodyService.instance;
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final bio = currentBiometrics(body, today);
|
final bio = currentBiometrics(body, today);
|
||||||
final sessions = body.sessions;
|
final (exercise, _, _) = bodyExercise(body, bio, today);
|
||||||
final weight = body.latestWeight?.$2;
|
final tiles = _tiles(body, bio, exercise);
|
||||||
final (exercise, stepsKcal, window) = bodyExercise(body, bio, today);
|
|
||||||
final bodyFat = body.latestBodyFat?.$2;
|
|
||||||
final profile = ProfileCard(onSaved: _onChanged);
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Body')),
|
appBar: AppBar(title: const Text('Body')),
|
||||||
body: SingleChildScrollView(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@ -159,36 +118,11 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (_profileAtTop) profile,
|
BodyTileGrid(tiles: tiles),
|
||||||
if (bio == null)
|
const SizedBox(height: AppSpacing.sm),
|
||||||
BodySection(
|
BodyNote(
|
||||||
title: 'Daily calorie target',
|
'Age ${bodyAgeText(body, today)} · synced with the PC.',
|
||||||
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.'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -196,18 +130,100 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
String _missing(BodyService body) {
|
/// The Activity card from the live service state.
|
||||||
if (body.latestWeight == null && body.birth == null) {
|
Widget _activityCard({required bool canConnect}) {
|
||||||
return 'Set your profile and enter your weight to see BMI, ideal '
|
final body = BodyService.instance;
|
||||||
'weight and calorie targets.';
|
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) {
|
/// Every section as a tile: the profile first while it is incomplete, last
|
||||||
final birth = DateTime.tryParse(body.birth ?? '');
|
/// otherwise (decided once, see [_profileAtTop]).
|
||||||
return birth == null ? 'unknown' : '${ageOn(birth, today)}';
|
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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
app/lib/screens/body_screen_data.dart
Normal file
78
app/lib/screens/body_screen_data.dart
Normal 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)}';
|
||||||
|
}
|
||||||
@ -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_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.dart';
|
||||||
import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Browses every banked food -- log-derived and manually curated -- with
|
/// 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 {
|
Future<void> _openAddDialog() async {
|
||||||
final result = await showDialog<FoodBankRecord>(
|
final result = await showDialog<FoodBankRecord>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -184,10 +193,7 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: FbTileGrid(records: _displayed, onOpen: _openDetail),
|
||||||
itemCount: _displayed.length,
|
|
||||||
itemBuilder: (context, i) => RecordTile(_displayed[i]),
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: _openAddDialog,
|
onPressed: _openAddDialog,
|
||||||
tooltip: 'Add manual entry',
|
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
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|||||||
111
app/lib/widgets/body/body_tile_grid.dart
Normal file
111
app/lib/widgets/body/body_tile_grid.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/lib/widgets/food_bank/fb_record_tile.dart
Normal file
35
app/lib/widgets/food_bank/fb_record_tile.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
app/lib/widgets/food_bank/fb_tile_grid.dart
Normal file
214
app/lib/widgets/food_bank/fb_tile_grid.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
|||||||
description: "Companion phone app for diet_guard: log meals on the go."
|
description: "Companion phone app for diet_guard: log meals on the go."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.126+1
|
version: 1.0.127+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@ -21,6 +21,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await seedPerson(store);
|
await seedPerson(store);
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Daily calorie target');
|
||||||
Future<void> choose(String word, String option) async {
|
Future<void> choose(String word, String option) async {
|
||||||
await tester.tap(find.text(word).first);
|
await tester.tap(find.text(word).first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@ -84,6 +85,7 @@ void main() {
|
|||||||
await body.setWeight('2026-09-20', 80);
|
await body.setWeight('2026-09-20', 80);
|
||||||
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
|
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Daily calorie target');
|
||||||
expect(find.textContaining('no workouts published yet'), findsOneWidget);
|
expect(find.textContaining('no workouts published yet'), findsOneWidget);
|
||||||
await tester.tap(find.text('Measured (MET)'));
|
await tester.tap(find.text('Measured (MET)'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@ -34,6 +34,7 @@ void main() {
|
|||||||
stepsSource = fake;
|
stepsSource = fake;
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
expect(fake.lastRead, isNull, reason: 'never reads before permission');
|
expect(fake.lastRead, isNull, reason: 'never reads before permission');
|
||||||
|
await openTile(tester, 'Activity');
|
||||||
|
|
||||||
await tester.tap(find.text('Connect Health Connect steps'));
|
await tester.tap(find.text('Connect Health Connect steps'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@ -53,6 +54,7 @@ void main() {
|
|||||||
final fake = FakeStepsSource();
|
final fake = FakeStepsSource();
|
||||||
stepsSource = fake;
|
stepsSource = fake;
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Activity');
|
||||||
expect(fake.lastRead, isNotNull);
|
expect(fake.lastRead, isNotNull);
|
||||||
expect(find.text('Connect Health Connect steps'), findsNothing);
|
expect(find.text('Connect Health Connect steps'), findsNothing);
|
||||||
});
|
});
|
||||||
@ -60,6 +62,7 @@ void main() {
|
|||||||
testWidgets('a Health Connect error just hides nothing', (tester) async {
|
testWidgets('a Health Connect error just hides nothing', (tester) async {
|
||||||
stepsSource = FakeStepsSource()..failWith = StateError('gone');
|
stepsSource = FakeStepsSource()..failWith = StateError('gone');
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Activity');
|
||||||
expect(find.text('Connect Health Connect steps'), findsOneWidget);
|
expect(find.text('Connect Health Connect steps'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/app_settings_service.dart';
|
||||||
import 'package:diet_guard_app/services/body_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/services/log_storage_service.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/body/body_tile_grid.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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.
|
/// The top edge of [finder]'s first match.
|
||||||
double topOf(WidgetTester tester, Finder finder) =>
|
double topOf(WidgetTester tester, Finder finder) =>
|
||||||
tester.getTopLeft(finder.first).dy;
|
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();
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import 'package:diet_guard_app/models/local_time.dart';
|
import 'package:diet_guard_app/models/local_time.dart';
|
||||||
import 'package:diet_guard_app/screens/body_screen.dart';
|
import 'package:diet_guard_app/screens/body_screen.dart';
|
||||||
import 'package:diet_guard_app/services/body_service.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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@ -16,18 +15,23 @@ void main() {
|
|||||||
});
|
});
|
||||||
tearDown(tearDownBodyServices);
|
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,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
expect(find.textContaining('Set your profile and enter'), findsOneWidget);
|
expect(tileTitles(tester).first, 'Profile');
|
||||||
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(find.textContaining('Age unknown'), findsOneWidget);
|
||||||
expect(
|
await openTile(tester, 'Daily calorie target');
|
||||||
topOf(tester, find.byType(ProfileCard)),
|
expect(find.textContaining('Set your profile and enter'), findsOneWidget);
|
||||||
lessThan(topOf(tester, find.text('Daily calorie target'))),
|
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', (
|
testWidgets('a weight without a profile asks for the profile', (
|
||||||
@ -35,6 +39,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await BodyService.instance.setWeight('2026-09-20', 80);
|
await BodyService.instance.setWeight('2026-09-20', 80);
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Daily calorie target');
|
||||||
expect(find.textContaining('Set your profile (birth'), findsOneWidget);
|
expect(find.textContaining('Set your profile (birth'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -45,6 +50,7 @@ void main() {
|
|||||||
sex: 'f',
|
sex: 'f',
|
||||||
);
|
);
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Daily calorie target');
|
||||||
expect(find.text('Enter your weight to see the numbers.'), findsOneWidget);
|
expect(find.text('Enter your weight to see the numbers.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,29 +59,44 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await seedPerson(store);
|
await seedPerson(store);
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
final goalTop = topOf(tester, find.text('Daily calorie target'));
|
final titles = tileTitles(tester);
|
||||||
expect(goalTop, lessThan(topOf(tester, find.text('Weight'))));
|
expect(titles.first, 'Daily calorie target');
|
||||||
final profileTop = topOf(tester, find.byType(ProfileCard));
|
expect(titles.indexOf('Weight'), 1);
|
||||||
expect(
|
expect(
|
||||||
profileTop,
|
titles.indexOf('Profile'),
|
||||||
greaterThan(topOf(tester, find.text('Week planner (info only)'))),
|
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:
|
// 80 kg, 180 cm, 30 y, male, lose 0.5 kg/week on Moderate:
|
||||||
// 1780 x 1.55 - 550 = 2209.
|
// 1780 x 1.55 - 550 = 2209.
|
||||||
|
await openTile(tester, 'Daily calorie target');
|
||||||
expect(find.text('2209'), findsOneWidget);
|
expect(find.text('2209'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('Maintenance 2759 kcal (Mifflin-St Jeor'),
|
find.textContaining('Maintenance 2759 kcal (Mifflin-St Jeor'),
|
||||||
findsOneWidget,
|
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);
|
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.text('Average of all'), findsOneWidget);
|
||||||
expect(find.textContaining('Broca'), findsNothing);
|
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));
|
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
|
// 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.
|
// all the data is from yesterday, so the window is that one day.
|
||||||
|
await openTile(tester, 'Activity');
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('Steps outside workouts: 9000 on ${yesterdayKey()}'),
|
find.textContaining('Steps outside workouts: 9000 on ${yesterdayKey()}'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@ -84,7 +105,6 @@ void main() {
|
|||||||
expect(find.text('Mean daily exercise, last 1 full days:'), findsOneWidget);
|
expect(find.text('Mean daily exercise, last 1 full days:'), findsOneWidget);
|
||||||
expect(find.textContaining('Evening run'), findsOneWidget);
|
expect(find.textContaining('Evening run'), findsOneWidget);
|
||||||
expect(find.textContaining('StrongLifts A'), findsOneWidget);
|
expect(find.textContaining('StrongLifts A'), findsOneWidget);
|
||||||
expect(find.textContaining('Age 30'), findsOneWidget);
|
|
||||||
expect(find.text('Connect Health Connect steps'), findsNothing);
|
expect(find.text('Connect Health Connect steps'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -93,19 +113,25 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await seedPerson(store);
|
await seedPerson(store);
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Body fat');
|
||||||
final field = find.widgetWithText(TextField, 'Body fat (%)');
|
final field = find.widgetWithText(TextField, 'Body fat (%)');
|
||||||
await tester.enterText(field, '99');
|
await tester.enterText(field, '99');
|
||||||
await tester.tap(find.text('Save').at(1));
|
await tester.tap(find.text('Save'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Body fat must be 3-70 %.'), findsOneWidget);
|
expect(find.text('Body fat must be 3-70 %.'), findsOneWidget);
|
||||||
|
|
||||||
await tester.enterText(field, '18,5');
|
await tester.enterText(field, '18,5');
|
||||||
await tester.tap(find.text('Save').at(1));
|
await tester.tap(find.text('Save'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(BodyService.instance.latestBodyFat?.$2, 18.5);
|
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.textContaining('(Katch-McArdle resting'), findsOneWidget);
|
||||||
expect(find.text('1778 kcal'), findsOneWidget);
|
|
||||||
expect(find.text('2206'), 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', (
|
testWidgets('saving and deleting a weight updates the numbers', (
|
||||||
@ -113,19 +139,22 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await seedPerson(store);
|
await seedPerson(store);
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Weight');
|
||||||
final field = find.widgetWithText(TextField, 'Weight (kg)');
|
final field = find.widgetWithText(TextField, 'Weight (kg)');
|
||||||
await tester.enterText(field, '5');
|
await tester.enterText(field, '5');
|
||||||
await tester.tap(find.text('Save').first);
|
await tester.tap(find.text('Save'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Weight must be 20-400 kg.'), findsOneWidget);
|
expect(find.text('Weight must be 20-400 kg.'), findsOneWidget);
|
||||||
|
|
||||||
await tester.enterText(field, '90,5');
|
await tester.enterText(field, '90,5');
|
||||||
await tester.tap(find.text('Save').first);
|
await tester.tap(find.text('Save'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final today = localDateKey(DateTime.now());
|
final today = localDateKey(DateTime.now());
|
||||||
expect(BodyService.instance.latestWeight, (today, 90.5));
|
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.tap(find.byTooltip('Delete this day').first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(BodyService.instance.latestWeight?.$1, yesterdayKey());
|
expect(BodyService.instance.latestWeight?.$1, yesterdayKey());
|
||||||
@ -133,6 +162,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('the profile form validates height and saves', (tester) async {
|
testWidgets('the profile form validates height and saves', (tester) async {
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
|
await openTile(tester, 'Profile');
|
||||||
final field = find.widgetWithText(TextField, 'Height (cm)');
|
final field = find.widgetWithText(TextField, 'Height (cm)');
|
||||||
await tester.enterText(field, '9');
|
await tester.enterText(field, '9');
|
||||||
await tester.tap(find.text('Save profile'));
|
await tester.tap(find.text('Save profile'));
|
||||||
|
|||||||
133
app/test/screens/fit/fit_screens_test.dart
Normal file
133
app/test/screens/fit/fit_screens_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
26
app/test/screens/fit/fit_support.dart
Normal file
26
app/test/screens/fit/fit_support.dart
Normal 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;
|
||||||
|
}
|
||||||
84
app/test/widgets/food_bank/fb_tile_grid_test.dart
Normal file
84
app/test/widgets/food_bank/fb_tile_grid_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user