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