Files
food-ai/client/lib/features/recipes/recommendations_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

143 lines
4.4 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: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('Попробовать снова'),
),
],
),
),
);
}
}