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:
263
client/lib/features/recipes/widgets/recipe_card.dart
Normal file
263
client/lib/features/recipes/widgets/recipe_card.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
91
client/lib/features/recipes/widgets/skeleton_card.dart
Normal file
91
client/lib/features/recipes/widgets/skeleton_card.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A pulsing placeholder card shown while recipes are loading from the AI.
|
||||
class SkeletonCard extends StatefulWidget {
|
||||
const SkeletonCard({super.key});
|
||||
|
||||
@override
|
||||
State<SkeletonCard> createState() => _SkeletonCardState();
|
||||
}
|
||||
|
||||
class _SkeletonCardState extends State<SkeletonCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
_anim = Tween<double>(begin: 0.25, end: 0.55).animate(
|
||||
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (context, _) {
|
||||
final color = Colors.grey.withValues(alpha: _anim.value);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(height: 180, color: color),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Bar(width: 220, height: 18, color: color),
|
||||
const SizedBox(height: 8),
|
||||
_Bar(width: 160, height: 14, color: color),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_Bar(width: 60, height: 12, color: color),
|
||||
const SizedBox(width: 12),
|
||||
_Bar(width: 60, height: 12, color: color),
|
||||
const SizedBox(width: 12),
|
||||
_Bar(width: 60, height: 12, color: color),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Bar extends StatelessWidget {
|
||||
final double width;
|
||||
final double height;
|
||||
final Color color;
|
||||
|
||||
const _Bar({required this.width, required this.height, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user