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>
553 lines
17 KiB
Dart
553 lines
17 KiB
Dart
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|