glp-companion/lib/features/nutrition/presentation/widgets/log_food_dialog.dart

230 lines
8.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/meal_type.dart';
import '../controllers/nutrition_controller.dart';
/// Opens the interactive food logging dialog.
///
/// Resolves to `true` when the meal was persisted, `false` when saving
/// failed, and `null` when the user dismissed the dialog.
Future<bool?> showLogFoodDialog(
BuildContext context, {
MealType initialMealType = MealType.breakfast,
}) {
return showDialog<bool>(
context: context,
builder: (context) => LogFoodDialog(initialMealType: initialMealType),
);
}
/// Form for logging a meal: slot, description, protein, calories, and
/// optional carbohydrate/fat macros.
class LogFoodDialog extends ConsumerStatefulWidget {
const LogFoodDialog({
super.key,
this.initialMealType = MealType.breakfast,
});
/// Meal slot pre-selected in the dropdown.
final MealType initialMealType;
@override
ConsumerState<LogFoodDialog> createState() => _LogFoodDialogState();
}
class _LogFoodDialogState extends ConsumerState<LogFoodDialog> {
late MealType _mealType = widget.initialMealType;
late final TextEditingController _descriptionController =
TextEditingController();
late final TextEditingController _proteinController =
TextEditingController();
late final TextEditingController _caloriesController =
TextEditingController();
late final TextEditingController _carbsController = TextEditingController();
late final TextEditingController _fatController = TextEditingController();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
bool _isSaving = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Log Food'),
content: SizedBox(
width: 440,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<MealType>(
initialValue: _mealType,
decoration: const InputDecoration(
labelText: 'Meal',
border: OutlineInputBorder(),
),
items: [
for (final type in MealType.values)
DropdownMenuItem(
value: type,
child: Text(type.displayName),
),
],
onChanged: (type) {
if (type == null) return;
setState(() => _mealType = type);
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'e.g. Greek yogurt with berries',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.sentences,
validator: (value) => (value == null || value.trim().isEmpty)
? 'Enter a description'
: null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _proteinController,
decoration: const InputDecoration(
labelText: 'Protein (g)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: _positiveDecimalValidator('protein'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _caloriesController,
decoration: const InputDecoration(
labelText: 'Calories',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: _nonNegativeIntValidator('calories'),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _carbsController,
decoration: const InputDecoration(
labelText: 'Carbs (g, optional)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: _optionalDecimalValidator('carbs'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _fatController,
decoration: const InputDecoration(
labelText: 'Fat (g, optional)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: _optionalDecimalValidator('fat'),
),
),
],
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: _isSaving ? null : _save,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.restaurant_menu),
label: const Text('Log Food'),
),
],
);
}
String? Function(String?) _positiveDecimalValidator(String field) =>
(value) {
final parsed = double.tryParse((value ?? '').trim());
if (parsed == null || parsed < 0) return 'Enter a valid $field';
return null;
};
String? Function(String?) _nonNegativeIntValidator(String field) =>
(value) {
final parsed = int.tryParse((value ?? '').trim());
if (parsed == null || parsed < 0) return 'Enter a valid $field';
return null;
};
String? Function(String?) _optionalDecimalValidator(String field) =>
(value) {
final raw = (value ?? '').trim();
if (raw.isEmpty) return null;
final parsed = double.tryParse(raw);
if (parsed == null || parsed < 0) return 'Enter a valid $field';
return null;
};
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _isSaving = true);
final saved = await ref.read(nutritionControllerProvider.notifier).logFood(
mealType: _mealType,
description: _descriptionController.text,
proteinGrams: double.parse(_proteinController.text.trim()),
calories: int.parse(_caloriesController.text.trim()),
carbsGrams: double.tryParse(_carbsController.text.trim()),
fatGrams: double.tryParse(_fatController.text.trim()),
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
@override
void dispose() {
_descriptionController.dispose();
_proteinController.dispose();
_caloriesController.dispose();
_carbsController.dispose();
_fatController.dispose();
super.dispose();
}
}