Files
food-ai/client/lib/features/scan/dish_result_screen.dart
dbastrikin ad00998344 feat: slim meal_diary — derive name and nutrition from dish/recipe
Remove denormalized columns (name, calories, protein_g, fat_g, carbs_g)
from meal_diary. Name is now resolved via JOIN with dishes/dish_translations;
macros are computed as recipe.*_per_serving * portions at query time.

- Add dish.Repository.FindOrCreateRecipe: finds or creates a minimal recipe
  stub seeded with AI-estimated macros
- recognition/handler: resolve recipe_id synchronously per candidate;
  simplify enrichDishInBackground to translations-only
- diary/handler: accept dish_id OR name; always resolve recipe_id via
  FindOrCreateRecipe before INSERT
- diary/entity: DishID is now non-nullable string; CreateRequest drops macros
- diary/repository: ListByDate and Create use JOIN to return computed macros
- ai/types: add RecipeID field to DishCandidate
- Update tests and wire_gen accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:28:37 +02:00

556 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/menu/menu_provider.dart';
import '../../features/home/home_provider.dart';
import '../../shared/models/meal_type.dart';
import 'recognition_service.dart';
/// Bottom sheet that shows dish recognition candidates and lets the user
/// confirm a dish entry before adding it to the diary.
class DishResultSheet extends ConsumerStatefulWidget {
const DishResultSheet({
super.key,
required this.dish,
required this.onAdded,
this.preselectedMealType,
});
final DishResult dish;
final VoidCallback onAdded;
final String? preselectedMealType;
@override
ConsumerState<DishResultSheet> createState() => _DishResultSheetState();
}
class _DishResultSheetState extends ConsumerState<DishResultSheet> {
late int _selectedIndex;
late int _portionGrams;
late String _mealType;
bool _saving = false;
final TextEditingController _portionController = TextEditingController();
@override
void initState() {
super.initState();
_selectedIndex = 0;
_portionGrams = widget.dish.candidates.isNotEmpty
? widget.dish.candidates.first.weightGrams
: 300;
_mealType = widget.preselectedMealType ??
kAllMealTypes.first.id;
_portionController.text = '$_portionGrams';
}
@override
void dispose() {
_portionController.dispose();
super.dispose();
}
DishCandidate get _selected => widget.dish.candidates[_selectedIndex];
/// Scales nutrition linearly to the current portion weight.
double _scale(double baseValue) {
final baseWeight = _selected.weightGrams;
if (baseWeight <= 0) return baseValue;
return baseValue * _portionGrams / baseWeight;
}
void _selectCandidate(int index) {
setState(() {
_selectedIndex = index;
_portionGrams = widget.dish.candidates[index].weightGrams;
_portionController.text = '$_portionGrams';
});
}
void _adjustPortion(int delta) {
final newValue = (_portionGrams + delta).clamp(10, 9999);
setState(() {
_portionGrams = newValue;
_portionController.text = '$newValue';
});
}
void _onPortionEdited(String value) {
final parsed = int.tryParse(value);
if (parsed != null && parsed >= 10) {
setState(() => _portionGrams = parsed.clamp(10, 9999));
}
}
Future<void> _addToDiary() async {
if (_saving) return;
setState(() => _saving = true);
final selectedDate = ref.read(selectedDateProvider);
final dateString = formatDateForDiary(selectedDate);
final scaledCalories = _scale(_selected.calories);
final scaledProtein = _scale(_selected.proteinG);
final scaledFat = _scale(_selected.fatG);
final scaledCarbs = _scale(_selected.carbsG);
try {
await ref.read(diaryProvider(dateString).notifier).add({
'date': dateString,
'meal_type': _mealType,
'name': _selected.dishName,
'calories': scaledCalories,
'protein_g': scaledProtein,
'fat_g': scaledFat,
'carbs_g': scaledCarbs,
'portion_g': _portionGrams,
'source': 'recognition',
if (_selected.dishId != null) 'dish_id': _selected.dishId,
});
if (mounted) widget.onAdded();
} catch (addError) {
debugPrint('Add to diary error: $addError');
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось добавить. Попробуйте ещё раз.')),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasCandidates = widget.dish.candidates.isNotEmpty;
return Column(
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Title row
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
child: Row(
children: [
Text('Распознано блюдо', style: theme.textTheme.titleMedium),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Scrollable content
Expanded(
child: hasCandidates
? ListView(
padding: const EdgeInsets.all(20),
children: [
_CandidatesSection(
candidates: widget.dish.candidates,
selectedIndex: _selectedIndex,
onSelect: _selectCandidate,
),
const SizedBox(height: 20),
_NutritionCard(
calories: _scale(_selected.calories),
proteinG: _scale(_selected.proteinG),
fatG: _scale(_selected.fatG),
carbsG: _scale(_selected.carbsG),
),
const SizedBox(height: 8),
Text(
'КБЖУ приблизительные — определены по фото.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
_PortionRow(
controller: _portionController,
onMinus: () => _adjustPortion(-10),
onPlus: () => _adjustPortion(10),
onChanged: _onPortionEdited,
),
const SizedBox(height: 20),
_MealTypeDropdown(
selected: _mealType,
onChanged: (value) {
if (value != null) setState(() => _mealType = value);
},
),
const SizedBox(height: 16),
],
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Блюдо не распознано',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Попробовать снова'),
),
],
),
),
),
// Bottom button
if (hasCandidates)
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: FilledButton(
onPressed: _saving ? null : _addToDiary,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Добавить в журнал'),
),
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Candidates selector
// ---------------------------------------------------------------------------
class _CandidatesSection extends StatelessWidget {
const _CandidatesSection({
required this.candidates,
required this.selectedIndex,
required this.onSelect,
});
final List<DishCandidate> candidates;
final int selectedIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Выберите блюдо', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
...candidates.asMap().entries.map((entry) {
final index = entry.key;
final candidate = entry.value;
final confPct = (candidate.confidence * 100).toInt();
final isSelected = index == selectedIndex;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onSelect(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
width: isSelected ? 2 : 1,
),
color: isSelected
? theme.colorScheme.primaryContainer
.withValues(alpha: 0.3)
: null,
),
child: Row(
children: [
Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
candidate.dishName,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
),
_ConfidenceBadge(confidence: confPct),
],
),
),
),
);
}),
],
);
}
}
class _ConfidenceBadge extends StatelessWidget {
const _ConfidenceBadge({required this.confidence});
final int confidence;
@override
Widget build(BuildContext context) {
final Color badgeColor;
if (confidence >= 80) {
badgeColor = Colors.green;
} else if (confidence >= 50) {
badgeColor = Colors.orange;
} else {
badgeColor = Colors.red;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$confidence%',
style: TextStyle(
color: badgeColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
);
}
}
// ---------------------------------------------------------------------------
// Nutrition card
// ---------------------------------------------------------------------------
class _NutritionCard extends StatelessWidget {
const _NutritionCard({
required this.calories,
required this.proteinG,
required this.fatG,
required this.carbsG,
});
final double calories;
final double proteinG;
final double fatG;
final double carbsG;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${calories.toInt()} ккал',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const Spacer(),
Tooltip(
message: 'Приблизительные значения на основе фото',
child: Text(
'',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_MacroChip(
label: 'Белки',
value: '${proteinG.toStringAsFixed(1)} г',
color: Colors.blue,
),
_MacroChip(
label: 'Жиры',
value: '${fatG.toStringAsFixed(1)} г',
color: Colors.orange,
),
_MacroChip(
label: 'Углеводы',
value: '${carbsG.toStringAsFixed(1)} г',
color: Colors.green,
),
],
),
],
),
),
);
}
}
class _MacroChip extends StatelessWidget {
const _MacroChip({
required this.label,
required this.value,
required this.color,
});
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: color,
),
),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
}
// ---------------------------------------------------------------------------
// Portion row
// ---------------------------------------------------------------------------
class _PortionRow extends StatelessWidget {
const _PortionRow({
required this.controller,
required this.onMinus,
required this.onPlus,
required this.onChanged,
});
final TextEditingController controller;
final VoidCallback onMinus;
final VoidCallback onPlus;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Порция', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
Row(
children: [
IconButton.outlined(
icon: const Icon(Icons.remove),
onPressed: onMinus,
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: onChanged,
decoration: const InputDecoration(
suffixText: 'г',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
IconButton.outlined(
icon: const Icon(Icons.add),
onPressed: onPlus,
),
],
),
],
);
}
}
// ---------------------------------------------------------------------------
// Meal type dropdown
// ---------------------------------------------------------------------------
class _MealTypeDropdown extends StatelessWidget {
const _MealTypeDropdown({
required this.selected,
required this.onChanged,
});
final String selected;
final ValueChanged<String?> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Приём пищи', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: selected,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
items: kAllMealTypes
.map((mealTypeOption) => DropdownMenuItem(
value: mealTypeOption.id,
child: Text(
'${mealTypeOption.emoji} ${mealTypeOption.label}'),
))
.toList(),
onChanged: onChanged,
),
],
);
}
}