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>
This commit is contained in:
271
client/lib/features/recipes/saved_recipes_screen.dart
Normal file
271
client/lib/features/recipes/saved_recipes_screen.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user