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>
556 lines
17 KiB
Dart
556 lines
17 KiB
Dart
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,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|