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 createState() => _DishResultSheetState(); } class _DishResultSheetState extends ConsumerState { 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 _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 (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 candidates; final int selectedIndex; final ValueChanged 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 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 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( initialValue: selected, decoration: const InputDecoration( border: OutlineInputBorder(), ), items: kAllMealTypes .map((mealTypeOption) => DropdownMenuItem( value: mealTypeOption.id, child: Text( '${mealTypeOption.emoji} ${mealTypeOption.label}'), )) .toList(), onChanged: onChanged, ), ], ); } }