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,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/models/recipe.dart';
import 'recipe_provider.dart';
import 'widgets/recipe_card.dart';
import 'widgets/skeleton_card.dart';
class RecommendationsScreen extends ConsumerWidget {
const RecommendationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(recommendationsProvider);
return Scaffold(
// AppBar is owned by RecipesScreen (tab host), but we add the
// refresh action via a floating action button inside this child.
body: state.when(
loading: () => _SkeletonList(),
error: (err, _) => _ErrorView(
message: err.toString(),
onRetry: () =>
ref.read(recommendationsProvider.notifier).load(),
),
data: (recipes) => _RecipeList(recipes: recipes),
),
floatingActionButton: FloatingActionButton(
heroTag: 'refresh_recommendations',
tooltip: 'Обновить рекомендации',
onPressed: state is AsyncLoading
? null
: () => ref.read(recommendationsProvider.notifier).load(),
child: state is AsyncLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
),
);
}
}
// ---------------------------------------------------------------------------
// Skeleton list — shown while AI is generating recipes
// ---------------------------------------------------------------------------
class _SkeletonList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: 3,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (_, __) => const SkeletonCard(),
);
}
}
// ---------------------------------------------------------------------------
// Loaded recipe list
// ---------------------------------------------------------------------------
class _RecipeList extends StatelessWidget {
final List<Recipe> recipes;
const _RecipeList({required this.recipes});
@override
Widget build(BuildContext context) {
if (recipes.isEmpty) {
return const Center(
child: Text('Нет рекомендаций. Нажмите ↻ чтобы получить рецепты.'),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), // room for FAB
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final recipe = recipes[index];
return RecipeCard(
recipe: recipe,
onTap: () => context.push(
'/recipe-detail',
extra: recipe,
),
);
},
);
}
}
// ---------------------------------------------------------------------------
// Error view
// ---------------------------------------------------------------------------
class _ErrorView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorView({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text(
'Не удалось получить рецепты',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
message,
style: const TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Попробовать снова'),
),
],
),
),
);
}
}