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,552 @@
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 '../../shared/models/saved_recipe.dart';
import 'recipe_provider.dart';
/// Unified detail screen for both recommendation recipes and saved recipes.
///
/// Pass a [Recipe] (from recommendations) or a [SavedRecipe] (from saved list)
/// via GoRouter's `extra` parameter.
class RecipeDetailScreen extends ConsumerStatefulWidget {
final Recipe? recipe;
final SavedRecipe? saved;
const RecipeDetailScreen({super.key, this.recipe, this.saved})
: assert(recipe != null || saved != null,
'Provide either recipe or saved');
@override
ConsumerState<RecipeDetailScreen> createState() => _RecipeDetailScreenState();
}
class _RecipeDetailScreenState extends ConsumerState<RecipeDetailScreen> {
bool _isSaving = false;
// ── Unified accessors ────────────────────────────────────────────────────
String get _title => widget.recipe?.title ?? widget.saved!.title;
String? get _description =>
widget.recipe?.description ?? widget.saved!.description;
String? get _imageUrl =>
widget.recipe?.imageUrl.isNotEmpty == true
? widget.recipe!.imageUrl
: widget.saved?.imageUrl;
String? get _cuisine => widget.recipe?.cuisine ?? widget.saved!.cuisine;
String? get _difficulty =>
widget.recipe?.difficulty ?? widget.saved!.difficulty;
int? get _prepTimeMin =>
widget.recipe?.prepTimeMin ?? widget.saved!.prepTimeMin;
int? get _cookTimeMin =>
widget.recipe?.cookTimeMin ?? widget.saved!.cookTimeMin;
int? get _servings => widget.recipe?.servings ?? widget.saved!.servings;
List<RecipeIngredient> get _ingredients =>
widget.recipe?.ingredients ?? widget.saved!.ingredients;
List<RecipeStep> get _steps =>
widget.recipe?.steps ?? widget.saved!.steps;
List<String> get _tags => widget.recipe?.tags ?? widget.saved!.tags;
NutritionInfo? get _nutrition =>
widget.recipe?.nutrition ?? widget.saved!.nutrition;
bool get _isFromSaved => widget.saved != null;
@override
Widget build(BuildContext context) {
final savedNotifier = ref.watch(savedRecipesProvider.notifier);
final isSaved = _isFromSaved ||
(widget.recipe != null && savedNotifier.isSaved(_title));
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(context),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
_title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
if (_description != null && _description!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_description!,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: AppColors.textSecondary),
),
],
const SizedBox(height: 16),
_MetaChips(
prepTimeMin: _prepTimeMin,
cookTimeMin: _cookTimeMin,
difficulty: _difficulty,
cuisine: _cuisine,
servings: _servings,
),
if (_nutrition != null) ...[
const SizedBox(height: 16),
_NutritionCard(nutrition: _nutrition!),
],
if (_tags.isNotEmpty) ...[
const SizedBox(height: 12),
_TagsRow(tags: _tags),
],
],
),
),
const Divider(height: 32),
_IngredientsSection(ingredients: _ingredients),
const Divider(height: 32),
_StepsSection(steps: _steps),
const SizedBox(height: 24),
// Save / Unsave button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _SaveButton(
isSaved: isSaved,
isLoading: _isSaving,
onPressed: () => _toggleSave(context, isSaved),
),
),
const SizedBox(height: 32),
],
),
),
],
),
);
}
Widget _buildAppBar(BuildContext context) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: _imageUrl != null && _imageUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: _imageUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: Colors.grey[200]),
errorWidget: (_, __, ___) => _PlaceholderImage(),
)
: _PlaceholderImage(),
),
);
}
Future<void> _toggleSave(BuildContext context, bool isSaved) async {
if (_isSaving) return;
HapticFeedback.lightImpact();
setState(() => _isSaving = true);
final notifier = ref.read(savedRecipesProvider.notifier);
try {
if (isSaved) {
final id = _isFromSaved
? widget.saved!.id
: notifier.savedId(_title);
if (id != null) {
final ok = await notifier.delete(id);
if (!ok && context.mounted) {
_showSnack(context, 'Не удалось удалить из сохранённых');
} else if (ok && _isFromSaved && context.mounted) {
Navigator.of(context).pop();
}
}
} else if (widget.recipe != null) {
final saved = await notifier.save(widget.recipe!);
if (saved == null && context.mounted) {
_showSnack(context, 'Не удалось сохранить рецепт');
}
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
void _showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}
// ---------------------------------------------------------------------------
// Sub-widgets
// ---------------------------------------------------------------------------
class _PlaceholderImage extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Center(child: Icon(Icons.restaurant, size: 64)),
);
}
class _MetaChips extends StatelessWidget {
final int? prepTimeMin;
final int? cookTimeMin;
final String? difficulty;
final String? cuisine;
final int? servings;
const _MetaChips({
this.prepTimeMin,
this.cookTimeMin,
this.difficulty,
this.cuisine,
this.servings,
});
@override
Widget build(BuildContext context) {
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (totalMin > 0)
_Chip(icon: Icons.access_time, label: '$totalMin мин'),
if (difficulty != null)
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
if (cuisine != null)
_Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)),
if (servings != null)
_Chip(icon: Icons.people, label: '$servings порц.'),
],
);
}
String _difficultyLabel(String d) => switch (d) {
'easy' => 'Легко',
'medium' => 'Средне',
'hard' => 'Сложно',
_ => d,
};
String _cuisineLabel(String c) => switch (c) {
'russian' => 'Русская',
'asian' => 'Азиатская',
'european' => 'Европейская',
'mediterranean' => 'Средиземноморская',
'american' => 'Американская',
_ => 'Другая',
};
}
class _Chip extends StatelessWidget {
final IconData icon;
final String label;
const _Chip({required this.icon, required this.label});
@override
Widget build(BuildContext context) => Chip(
avatar: Icon(icon, size: 14),
label: Text(label, style: const TextStyle(fontSize: 12)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
);
}
class _NutritionCard extends StatelessWidget {
final NutritionInfo nutrition;
const _NutritionCard({required this.nutrition});
@override
Widget build(BuildContext context) {
return Card(
color: AppColors.primary.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'КБЖУ на порцию',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Tooltip(
message: 'Значения рассчитаны приблизительно с помощью ИИ',
child: Text(
'',
style: TextStyle(
color: AppColors.accent,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_NutCell(
label: 'Калории', value: '${nutrition.calories.round()}'),
_NutCell(
label: 'Белки', value: '${nutrition.proteinG.round()} г'),
_NutCell(
label: 'Жиры', value: '${nutrition.fatG.round()} г'),
_NutCell(
label: 'Углев.', value: '${nutrition.carbsG.round()} г'),
],
),
],
),
),
);
}
}
class _NutCell extends StatelessWidget {
final String label;
final String value;
const _NutCell({required this.label, required this.value});
@override
Widget build(BuildContext context) => Column(
children: [
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
);
}
class _TagsRow extends StatelessWidget {
final List<String> tags;
const _TagsRow({required this.tags});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 6,
runSpacing: 4,
children: tags
.map(
(t) => Chip(
label: Text(t, style: const TextStyle(fontSize: 11)),
backgroundColor: AppColors.primaryLight.withValues(alpha: 0.3),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
)
.toList(),
);
}
}
class _IngredientsSection extends StatelessWidget {
final List<RecipeIngredient> ingredients;
const _IngredientsSection({required this.ingredients});
@override
Widget build(BuildContext context) {
if (ingredients.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ингредиенты',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
...ingredients.map(
(ing) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
const Icon(Icons.circle, size: 6, color: AppColors.primary),
const SizedBox(width: 10),
Expanded(child: Text(ing.name)),
Text(
'${_formatAmount(ing.amount)} ${ing.unit}',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13),
),
],
),
),
),
],
),
);
}
String _formatAmount(double amount) {
if (amount == amount.truncate()) return amount.toInt().toString();
return amount.toStringAsFixed(1);
}
}
class _StepsSection extends StatelessWidget {
final List<RecipeStep> steps;
const _StepsSection({required this.steps});
@override
Widget build(BuildContext context) {
if (steps.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Приготовление',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
...steps.map((step) => _StepTile(step: step)),
],
),
);
}
}
class _StepTile extends StatelessWidget {
final RecipeStep step;
const _StepTile({required this.step});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Step number badge
Container(
width: 28,
height: 28,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${step.number}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(step.description),
if (step.timerSeconds != null) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.timer_outlined,
size: 14, color: AppColors.accent),
const SizedBox(width: 4),
Text(
_formatTimer(step.timerSeconds!),
style: const TextStyle(
color: AppColors.accent, fontSize: 12),
),
],
),
],
],
),
),
],
),
);
}
String _formatTimer(int seconds) {
if (seconds < 60) return '$seconds сек';
final m = seconds ~/ 60;
final s = seconds % 60;
return s == 0 ? '$m мин' : '$m мин $s сек';
}
}
class _SaveButton extends StatelessWidget {
final bool isSaved;
final bool isLoading;
final VoidCallback onPressed;
const _SaveButton({
required this.isSaved,
required this.isLoading,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(isSaved ? Icons.favorite : Icons.favorite_border),
label: Text(isSaved ? 'Сохранено' : 'Сохранить'),
style: ElevatedButton.styleFrom(
backgroundColor:
isSaved ? Colors.red[100] : AppColors.primary,
foregroundColor: isSaved ? Colors.red[800] : Colors.white,
),
),
);
}
}