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:
142
client/lib/features/recipes/recommendations_screen.dart
Normal file
142
client/lib/features/recipes/recommendations_screen.dart
Normal 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('Попробовать снова'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user