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:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

View 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,
);
}

View 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),
),
);
}