Files
food-ai/client/lib/features/scan/dish_result_screen.dart
dbastrikin cf69a4a3d9 feat: dish recognition job context, diary linkage, home widget, history page
Backend:
- Rename recognition_jobs → dish_recognition_jobs; add target_date and
  target_meal_type columns to capture scan context at submission time
- Add job_id FK on meal_diary so entries are linked to their origin job
- New GET /ai/jobs endpoint returns today's unlinked jobs for the current user
- diary.Entry and CreateRequest gain job_id field; repository reads/writes it
- CORS middleware: allow Accept-Language and Cache-Control headers
- Logging middleware: implement http.Flusher on responseWriter (needed for SSE)
- Consolidate migrations into a single 001_initial_schema.sql

Flutter:
- POST /ai/recognize-dish now sends target_date and target_meal_type
- DishResultSheet accepts jobId; _addToDiary includes it in the diary payload,
  saves last-used meal type to SharedPreferences, invalidates todayJobsProvider
- TodayJobsNotifier + todayJobsProvider: loads unlinked jobs via GET /ai/jobs
- Home screen shows _TodayJobsWidget (up to 3 tiles) between macros and meals;
  tapping a done tile reopens DishResultSheet with the stored result
- Quick Actions row: third button "История" → /scan/history
- New RecognitionHistoryScreen: full-screen list of today's unlinked jobs
- LocalPreferences wrapper over SharedPreferences (last_used_meal_type)
- app_theme: apply Google Fonts Roboto as default font family

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 16:11:21 +02:00

562 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 '../../core/storage/local_preferences_provider.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,
this.jobId,
});
final DishResult dish;
final VoidCallback onAdded;
final String? preselectedMealType;
final String? jobId;
@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 (widget.jobId != null) 'job_id': widget.jobId,
});
await ref.read(localPreferencesProvider).setLastUsedMealType(_mealType);
ref.invalidate(todayJobsProvider);
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,
),
],
);
}
}