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>
272 lines
8.9 KiB
Dart
272 lines
8.9 KiB
Dart
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]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|