Files
food-ai/client/lib/features/recipes/saved_recipes_screen.dart
dbastrikin e57ff8e06c feat: implement Iteration 1 — AI recipe recommendations
Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
  retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go

Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()

Project:
- Add CLAUDE.md with English-only rule for comments and commit messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 22:43:29 +02:00

272 lines
8.9 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:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_colors.dart';
import '../../shared/models/saved_recipe.dart';
import 'recipe_provider.dart';
class SavedRecipesScreen extends ConsumerWidget {
const SavedRecipesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(savedRecipesProvider);
return state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text('Не удалось загрузить сохранённые рецепты'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.read(savedRecipesProvider.notifier).load(),
child: const Text('Повторить'),
),
],
),
),
data: (recipes) => recipes.isEmpty
? const _EmptyState()
: _SavedList(recipes: recipes),
);
}
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
class _SavedList extends StatelessWidget {
final List<SavedRecipe> recipes;
const _SavedList({required this.recipes});
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_SavedRecipeItem(recipe: recipes[index]),
);
}
}
// ---------------------------------------------------------------------------
// Single item with swipe-to-delete
// ---------------------------------------------------------------------------
class _SavedRecipeItem extends ConsumerWidget {
final SavedRecipe recipe;
const _SavedRecipeItem({required this.recipe});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: ValueKey(recipe.id),
direction: DismissDirection.endToStart,
background: _DeleteBackground(),
confirmDismiss: (_) => _confirmDelete(context),
onDismissed: (_) async {
final ok =
await ref.read(savedRecipesProvider.notifier).delete(recipe.id);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось удалить рецепт')),
);
}
},
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.push('/recipe-detail', extra: recipe),
child: Row(
children: [
// Thumbnail
_Thumbnail(imageUrl: recipe.imageUrl),
// Info
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (recipe.nutrition != null) ...[
const SizedBox(height: 4),
Text(
'${recipe.nutrition!.calories.round()} ккал · '
'${recipe.nutrition!.proteinG.round()} б · '
'${recipe.nutrition!.fatG.round()} ж · '
'${recipe.nutrition!.carbsG.round()} у',
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
if (recipe.prepTimeMin != null ||
recipe.cookTimeMin != null) ...[
const SizedBox(height: 4),
Text(
_timeLabel(recipe.prepTimeMin, recipe.cookTimeMin),
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
],
),
),
),
// Delete button
IconButton(
icon: const Icon(Icons.delete_outline,
color: AppColors.textSecondary),
onPressed: () async {
final confirmed = await _confirmDelete(context);
if (confirmed == true && context.mounted) {
final ok = await ref
.read(savedRecipesProvider.notifier)
.delete(recipe.id);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось удалить рецепт')),
);
}
}
},
),
],
),
),
),
);
}
String _timeLabel(int? prep, int? cook) {
final total = (prep ?? 0) + (cook ?? 0);
return total > 0 ? '$total мин' : '';
}
Future<bool?> _confirmDelete(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить рецепт?'),
content: Text('«${recipe.title}» будет удалён из сохранённых.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Удалить'),
),
],
),
);
}
}
class _Thumbnail extends StatelessWidget {
final String? imageUrl;
const _Thumbnail({this.imageUrl});
@override
Widget build(BuildContext context) {
if (imageUrl == null || imageUrl!.isEmpty) {
return Container(
width: 80,
height: 80,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Icon(Icons.restaurant),
);
}
return CachedNetworkImage(
imageUrl: imageUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(width: 80, height: 80, color: Colors.grey[200]),
errorWidget: (_, __, ___) => Container(
width: 80,
height: 80,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Icon(Icons.restaurant),
),
);
}
}
class _DeleteBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite_border,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Нет сохранённых рецептов',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'Сохраняйте рецепты из рекомендаций,\nнажимая на ♡',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[500]),
),
],
),
),
);
}
}