mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +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 'package:diet_guard_app/services/activity_kcal.dart';
|
||||
import 'package:diet_guard_app/screens/body_screen_data.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_calc.dart';
|
||||
import 'package:diet_guard_app/services/body_energy.dart';
|
||||
import 'package:diet_guard_app/services/body_prefs.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/body_steps.dart';
|
||||
import 'package:diet_guard_app/services/health_steps.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:diet_guard_app/widgets/body/activity_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_stats_cards.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_tile_grid.dart';
|
||||
import 'package:diet_guard_app/widgets/body/goal_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/profile_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/week_plan_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/weight_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Builds the person from the stored profile and newest weight, or null
|
||||
/// when anything the formulas need is missing.
|
||||
Biometrics? currentBiometrics(BodyService body, DateTime today) {
|
||||
final birth = DateTime.tryParse(body.birth ?? '');
|
||||
final height = body.heightCm;
|
||||
final sex = body.sex;
|
||||
final weight = body.latestWeight;
|
||||
if (birth == null || height == null || sex == null || weight == null) {
|
||||
return null;
|
||||
}
|
||||
return Biometrics(
|
||||
weightKg: weight.$2,
|
||||
heightCm: height,
|
||||
ageYears: ageOn(birth, today).toDouble(),
|
||||
isMale: sex == 'm',
|
||||
);
|
||||
}
|
||||
|
||||
/// Steps outside workouts by day, as stored.
|
||||
Map<String, int> stepsByDay(BodyService body) =>
|
||||
body.document.steps.map((day, entry) => MapEntry(day, entry.n));
|
||||
|
||||
/// Mean daily exercise per formula (steps included), the steps' own share,
|
||||
/// and the number of full days averaged -- like the PC's `_body_view`: the
|
||||
/// two means are null without a person, or without any published workout
|
||||
/// or step count.
|
||||
(Map<String, double>?, double?, int) bodyExercise(
|
||||
BodyService body,
|
||||
Biometrics? bio,
|
||||
DateTime today,
|
||||
) {
|
||||
final sessions = body.sessions;
|
||||
final steps = stepsByDay(body);
|
||||
if (bio == null || (sessions == null && steps.isEmpty)) {
|
||||
return (null, null, 0);
|
||||
}
|
||||
final byDay = steps.map((day, n) => MapEntry(day, stepKcal(n, bio)));
|
||||
final weight = bio.weightKg;
|
||||
final all = sessions ?? const <Session>[];
|
||||
return (
|
||||
dailyExercise(all, weight, today, byDay),
|
||||
extraShare(all, today, byDay),
|
||||
windowDays(all, today, byDay),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sums each day's non-deleted kcal.
|
||||
Map<String, double> kcalByDay(DayLog log) => {
|
||||
for (final entry in log.entries)
|
||||
entry.key: entry.value
|
||||
.where((food) => !food.deleted)
|
||||
.fold<double>(0, (sum, food) => sum + food.kcal),
|
||||
};
|
||||
export 'package:diet_guard_app/screens/body_screen_data.dart';
|
||||
|
||||
/// The Body screen.
|
||||
class BodyScreen extends StatefulWidget {
|
||||
@ -100,7 +50,17 @@ class _BodyScreenState extends State<BodyScreen> {
|
||||
late final bool _profileAtTop = !BodyService.instance.profileComplete;
|
||||
|
||||
/// Whether the Health Connect button shows: supported and not yet granted.
|
||||
bool _canConnectSteps = false;
|
||||
///
|
||||
/// A notifier, not a bool: the Activity sheet stays open while the
|
||||
/// permission is granted, and its button must disappear and its numbers
|
||||
/// refresh without reopening the sheet.
|
||||
final ValueNotifier<bool> _canConnectSteps = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_canConnectSteps.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -117,7 +77,8 @@ class _BodyScreenState extends State<BodyScreen> {
|
||||
final granted = await source.hasPermission().catchError((_) => false);
|
||||
if (granted) await refreshSteps();
|
||||
if (!mounted) return;
|
||||
setState(() => _canConnectSteps = !granted);
|
||||
_canConnectSteps.value = !granted;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _connectSteps() async {
|
||||
@ -126,7 +87,8 @@ class _BodyScreenState extends State<BodyScreen> {
|
||||
);
|
||||
if (granted) await refreshSteps();
|
||||
if (!mounted) return;
|
||||
setState(() => _canConnectSteps = !granted);
|
||||
_canConnectSteps.value = !granted;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _loadLog() async {
|
||||
@ -144,14 +106,11 @@ class _BodyScreenState extends State<BodyScreen> {
|
||||
final body = BodyService.instance;
|
||||
final today = DateTime.now();
|
||||
final bio = currentBiometrics(body, today);
|
||||
final sessions = body.sessions;
|
||||
final weight = body.latestWeight?.$2;
|
||||
final (exercise, stepsKcal, window) = bodyExercise(body, bio, today);
|
||||
final bodyFat = body.latestBodyFat?.$2;
|
||||
final profile = ProfileCard(onSaved: _onChanged);
|
||||
final (exercise, _, _) = bodyExercise(body, bio, today);
|
||||
final tiles = _tiles(body, bio, exercise);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Body')),
|
||||
body: SingleChildScrollView(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
@ -159,36 +118,11 @@ class _BodyScreenState extends State<BodyScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_profileAtTop) profile,
|
||||
if (bio == null)
|
||||
BodySection(
|
||||
title: 'Daily calorie target',
|
||||
children: [BodyNote(_missing(body))],
|
||||
)
|
||||
else
|
||||
GoalCard(bio: bio, exercise: exercise, bodyFatPct: bodyFat),
|
||||
WeightCard(onChanged: _onChanged),
|
||||
if (bio != null) ...[
|
||||
BmiCard(bio: bio),
|
||||
IdealWeightCard(bio: bio),
|
||||
BmrCard(bio: bio, bodyFatPct: bodyFat),
|
||||
],
|
||||
ActivityCard(
|
||||
sessions: sessions,
|
||||
weightKg: weight,
|
||||
exercise: exercise,
|
||||
steps: stepsByDay(body),
|
||||
stepsKcal: stepsKcal,
|
||||
window: window,
|
||||
onConnectSteps: _canConnectSteps ? _connectSteps : null,
|
||||
BodyTileGrid(tiles: tiles),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
BodyNote(
|
||||
'Age ${bodyAgeText(body, today)} · synced with the PC.',
|
||||
),
|
||||
WeekPlanCard(
|
||||
defaultTarget: AppSettingsService.dailyKcalGoal,
|
||||
logged: _logged,
|
||||
),
|
||||
if (!_profileAtTop) profile,
|
||||
BodyFatCard(onChanged: _onChanged),
|
||||
BodyNote('Age ${_age(body, today)} · synced with the PC.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -196,18 +130,100 @@ class _BodyScreenState extends State<BodyScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _missing(BodyService body) {
|
||||
if (body.latestWeight == null && body.birth == null) {
|
||||
return 'Set your profile and enter your weight to see BMI, ideal '
|
||||
'weight and calorie targets.';
|
||||
/// The Activity card from the live service state.
|
||||
Widget _activityCard({required bool canConnect}) {
|
||||
final body = BodyService.instance;
|
||||
final (exercise, stepsKcal, window) = bodyExercise(
|
||||
body,
|
||||
currentBiometrics(body, DateTime.now()),
|
||||
DateTime.now(),
|
||||
);
|
||||
return ActivityCard(
|
||||
sessions: body.sessions,
|
||||
weightKg: body.latestWeight?.$2,
|
||||
exercise: exercise,
|
||||
steps: stepsByDay(body),
|
||||
stepsKcal: stepsKcal,
|
||||
window: window,
|
||||
onConnectSteps: canConnect ? _connectSteps : null,
|
||||
);
|
||||
}
|
||||
if (body.latestWeight == null) return 'Enter your weight to see the numbers.';
|
||||
return 'Set your profile (birth date, height, sex) to see the numbers.';
|
||||
}
|
||||
|
||||
String _age(BodyService body, DateTime today) {
|
||||
final birth = DateTime.tryParse(body.birth ?? '');
|
||||
return birth == null ? 'unknown' : '${ageOn(birth, today)}';
|
||||
/// Every section as a tile: the profile first while it is incomplete, last
|
||||
/// otherwise (decided once, see [_profileAtTop]).
|
||||
List<BodyTileSpec> _tiles(
|
||||
BodyService body,
|
||||
Biometrics? bio,
|
||||
Map<String, double>? exercise,
|
||||
) {
|
||||
final weight = body.latestWeight?.$2;
|
||||
final bodyFat = body.latestBodyFat?.$2;
|
||||
final profile = BodyTileSpec(
|
||||
title: 'Profile',
|
||||
builder: (_) => ProfileCard(onSaved: _onChanged),
|
||||
);
|
||||
final goal = bio == null
|
||||
? null
|
||||
: goalTarget(
|
||||
savedGoal(body),
|
||||
tableBmr(bio, bodyFat).value!,
|
||||
tableBmr(bio, bodyFat).name,
|
||||
exercise,
|
||||
).kcal;
|
||||
return [
|
||||
if (_profileAtTop) profile,
|
||||
BodyTileSpec(
|
||||
title: 'Daily calorie target',
|
||||
value: goal == null ? null : '$goal kcal',
|
||||
builder: (_) => bio == null
|
||||
? BodySection(
|
||||
title: 'Daily calorie target',
|
||||
children: [BodyNote(bodyMissingNote(body))],
|
||||
)
|
||||
: GoalCard(bio: bio, exercise: exercise, bodyFatPct: bodyFat),
|
||||
),
|
||||
BodyTileSpec(
|
||||
title: 'Weight',
|
||||
value: weight == null ? null : formatKg(weight),
|
||||
builder: (_) => WeightCard(onChanged: _onChanged),
|
||||
),
|
||||
if (bio != null) ...[
|
||||
BodyTileSpec(
|
||||
title: 'BMI',
|
||||
value: oneDecimal(bmiReport(bio).bmi),
|
||||
builder: (_) => BmiCard(bio: bio),
|
||||
),
|
||||
BodyTileSpec(
|
||||
title: 'Ideal weight',
|
||||
builder: (_) => IdealWeightCard(bio: bio),
|
||||
),
|
||||
BodyTileSpec(
|
||||
title: 'Resting metabolism (BMR)',
|
||||
value: '${whole(tableBmr(bio, bodyFat).value!)} kcal',
|
||||
builder: (_) => BmrCard(bio: bio, bodyFatPct: bodyFat),
|
||||
),
|
||||
],
|
||||
BodyTileSpec(
|
||||
title: 'Activity',
|
||||
builder: (_) => ValueListenableBuilder<bool>(
|
||||
valueListenable: _canConnectSteps,
|
||||
builder: (_, canConnect, _) => _activityCard(canConnect: canConnect),
|
||||
),
|
||||
),
|
||||
BodyTileSpec(
|
||||
title: 'Week planner (info only)',
|
||||
builder: (_) => WeekPlanCard(
|
||||
defaultTarget: AppSettingsService.dailyKcalGoal,
|
||||
logged: _logged,
|
||||
),
|
||||
),
|
||||
if (!_profileAtTop) profile,
|
||||
BodyTileSpec(
|
||||
title: 'Body fat',
|
||||
value: bodyFat == null ? null : '${oneDecimal(bodyFat)} %',
|
||||
builder: (_) => BodyFatCard(onChanged: _onChanged),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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_filter.dart';
|
||||
import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.dart';
|
||||
import 'package:diet_guard_app/widgets/food_bank/fb_record_tile.dart';
|
||||
import 'package:diet_guard_app/widgets/food_bank/fb_tile_grid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Browses every banked food -- log-derived and manually curated -- with
|
||||
@ -129,6 +131,13 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows the full macro line for a tapped tile.
|
||||
Future<void> _openDetail(FoodBankRecord record) => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
builder: (_) => SafeArea(child: RecordTile(record)),
|
||||
);
|
||||
|
||||
Future<void> _openAddDialog() async {
|
||||
final result = await showDialog<FoodBankRecord>(
|
||||
context: context,
|
||||
@ -184,10 +193,7 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _displayed.length,
|
||||
itemBuilder: (context, i) => RecordTile(_displayed[i]),
|
||||
),
|
||||
: FbTileGrid(records: _displayed, onOpen: _openDetail),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _openAddDialog,
|
||||
tooltip: 'Add manual entry',
|
||||
@ -196,43 +202,3 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record tile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One banked food: its name, macros, and how often it has been logged.
|
||||
class RecordTile extends StatelessWidget {
|
||||
/// Creates a [RecordTile] for [record].
|
||||
const RecordTile(this.record, {super.key});
|
||||
|
||||
/// The banked record this tile renders.
|
||||
final FoodBankRecord record;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final macros =
|
||||
'P ${record.proteinG.toStringAsFixed(0)} g '
|
||||
'C ${record.carbsG.toStringAsFixed(0)} g '
|
||||
'F ${record.fatG.toStringAsFixed(0)} g';
|
||||
final per = record.grams > 0
|
||||
? ' per ${record.grams.toStringAsFixed(0)} g'
|
||||
: '';
|
||||
return ListTile(
|
||||
title: Text(record.desc),
|
||||
subtitle: Text(
|
||||
'${record.kcal.toStringAsFixed(0)} kcal$per · $macros',
|
||||
),
|
||||
trailing: record.count > 0
|
||||
? Text(
|
||||
'×${record.count.toStringAsFixed(0)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter sheet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.126+1
|
||||
version: 1.0.127+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -21,6 +21,7 @@ void main() {
|
||||
) async {
|
||||
await seedPerson(store);
|
||||
await pumpBody(tester);
|
||||
await openTile(tester, 'Daily calorie target');
|
||||
Future<void> choose(String word, String option) async {
|
||||
await tester.tap(find.text(word).first);
|
||||
await tester.pumpAndSettle();
|
||||
@ -84,6 +85,7 @@ void main() {
|
||||
await body.setWeight('2026-09-20', 80);
|
||||
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
|
||||
await pumpBody(tester);
|
||||
await openTile(tester, 'Daily calorie target');
|
||||
expect(find.textContaining('no workouts published yet'), findsOneWidget);
|
||||
await tester.tap(find.text('Measured (MET)'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:diet_guard_app/screens/body_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_tile_grid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@ -87,3 +88,30 @@ Future<void> seedPerson(MemoryDocStore store, {Object? goal}) async {
|
||||
/// The top edge of [finder]'s first match.
|
||||
double topOf(WidgetTester tester, Finder finder) =>
|
||||
tester.getTopLeft(finder.first).dy;
|
||||
|
||||
/// The overview tile titled [title].
|
||||
Finder tile(String title) =>
|
||||
find.descendant(of: find.byType(BodyTileGrid), matching: find.text(title));
|
||||
|
||||
/// Opens the sheet behind the tile titled [title].
|
||||
Future<void> openTile(WidgetTester tester, String title) async {
|
||||
await tester.tap(tile(title));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Dismisses the open sheet by tapping its scrim.
|
||||
Future<void> closeSheet(WidgetTester tester) async {
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// [finder] restricted to the open sheet (the tiles stay mounted behind it).
|
||||
Finder inSheet(Finder finder) =>
|
||||
find.descendant(of: find.byType(BottomSheet), matching: finder);
|
||||
|
||||
/// Titles of the overview tiles, in display order.
|
||||
List<String> tileTitles(WidgetTester tester) => tester
|
||||
.widget<BodyTileGrid>(find.byType(BodyTileGrid))
|
||||
.tiles
|
||||
.map((t) => t.title)
|
||||
.toList();
|
||||
|
||||
@ -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'));
|
||||
|
||||
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