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:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

View 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]),
),
],
),
),
);
}
}