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

264 lines
7.8 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:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../shared/models/recipe.dart';
import '../recipe_provider.dart';
/// Card shown in the recommendations list.
/// Shows the photo, title, nutrition summary, time and difficulty.
/// The ♡ button saves / unsaves the recipe.
class RecipeCard extends ConsumerWidget {
final Recipe recipe;
final VoidCallback onTap;
const RecipeCard({
super.key,
required this.recipe,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final savedNotifier = ref.watch(savedRecipesProvider.notifier);
final isSaved = ref.watch(
savedRecipesProvider.select(
(_) => savedNotifier.isSaved(recipe.title),
),
);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Photo
Stack(
children: [
_RecipeImage(imageUrl: recipe.imageUrl, title: recipe.title),
// Save button
Positioned(
top: 8,
right: 8,
child: _SaveButton(
isSaved: isSaved,
onPressed: () => _toggleSave(context, ref, isSaved),
),
),
],
),
// Content
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (recipe.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
recipe.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 10),
_MetaRow(recipe: recipe),
if (recipe.nutrition != null) ...[
const SizedBox(height: 8),
_NutritionRow(nutrition: recipe.nutrition!),
],
],
),
),
],
),
),
);
}
Future<void> _toggleSave(
BuildContext context,
WidgetRef ref,
bool isSaved,
) async {
HapticFeedback.lightImpact();
final notifier = ref.read(savedRecipesProvider.notifier);
if (isSaved) {
final id = notifier.savedId(recipe.title);
if (id != null) await notifier.delete(id);
} else {
final saved = await notifier.save(recipe);
if (saved == null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось сохранить рецепт')),
);
}
}
}
}
class _RecipeImage extends StatelessWidget {
final String imageUrl;
final String title;
const _RecipeImage({required this.imageUrl, required this.title});
@override
Widget build(BuildContext context) {
if (imageUrl.isEmpty) {
return Container(
height: 180,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Center(child: Icon(Icons.restaurant, size: 48)),
);
}
return CachedNetworkImage(
imageUrl: imageUrl,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
height: 180,
color: Colors.grey.withValues(alpha: 0.3),
),
errorWidget: (_, __, ___) => Container(
height: 180,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Center(child: Icon(Icons.restaurant, size: 48)),
),
);
}
}
class _SaveButton extends StatelessWidget {
final bool isSaved;
final VoidCallback onPressed;
const _SaveButton({required this.isSaved, required this.onPressed});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black45,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
isSaved ? Icons.favorite : Icons.favorite_border,
color: isSaved ? Colors.red : Colors.white,
size: 22,
),
),
),
);
}
}
class _MetaRow extends StatelessWidget {
final Recipe recipe;
const _MetaRow({required this.recipe});
@override
Widget build(BuildContext context) {
final totalMin = recipe.prepTimeMin + recipe.cookTimeMin;
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
);
return Row(
children: [
const Icon(Icons.access_time, size: 14, color: AppColors.textSecondary),
const SizedBox(width: 3),
Text('$totalMin мин', style: style),
const SizedBox(width: 12),
const Icon(Icons.bar_chart, size: 14, color: AppColors.textSecondary),
const SizedBox(width: 3),
Text(_difficultyLabel(recipe.difficulty), style: style),
if (recipe.cuisine.isNotEmpty) ...[
const SizedBox(width: 12),
const Icon(Icons.public, size: 14, color: AppColors.textSecondary),
const SizedBox(width: 3),
Text(_cuisineLabel(recipe.cuisine), style: style),
],
],
);
}
String _difficultyLabel(String d) => switch (d) {
'easy' => 'Легко',
'medium' => 'Средне',
'hard' => 'Сложно',
_ => d,
};
String _cuisineLabel(String c) => switch (c) {
'russian' => 'Русская',
'asian' => 'Азиатская',
'european' => 'Европейская',
'mediterranean' => 'Средиземноморская',
'american' => 'Американская',
_ => 'Другая',
};
}
class _NutritionRow extends StatelessWidget {
final NutritionInfo nutrition;
const _NutritionRow({required this.nutrition});
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
fontSize: 11,
);
return Row(
children: [
Text('', style: style?.copyWith(color: AppColors.accent)),
_NutItem(label: 'ккал', value: nutrition.calories.round(), style: style),
const SizedBox(width: 8),
_NutItem(label: 'б', value: nutrition.proteinG.round(), style: style),
const SizedBox(width: 8),
_NutItem(label: 'ж', value: nutrition.fatG.round(), style: style),
const SizedBox(width: 8),
_NutItem(label: 'у', value: nutrition.carbsG.round(), style: style),
],
);
}
}
class _NutItem extends StatelessWidget {
final String label;
final int value;
final TextStyle? style;
const _NutItem({required this.label, required this.value, this.style});
@override
Widget build(BuildContext context) => Text(
'$value $label',
style: style,
);
}