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

View File

@@ -0,0 +1,109 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart';
import 'recipe_service.dart';
// ---------------------------------------------------------------------------
// Service provider
// ---------------------------------------------------------------------------
final recipeServiceProvider = Provider<RecipeService>((ref) {
return RecipeService(ref.read(apiClientProvider));
});
// ---------------------------------------------------------------------------
// Recommendations
// ---------------------------------------------------------------------------
class RecommendationsNotifier
extends StateNotifier<AsyncValue<List<Recipe>>> {
final RecipeService _service;
RecommendationsNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
Future<void> load({int count = 5}) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => _service.getRecommendations(count: count),
);
}
}
final recommendationsProvider = StateNotifierProvider<RecommendationsNotifier,
AsyncValue<List<Recipe>>>((ref) {
return RecommendationsNotifier(ref.read(recipeServiceProvider));
});
// ---------------------------------------------------------------------------
// Saved recipes
// ---------------------------------------------------------------------------
class SavedRecipesNotifier
extends StateNotifier<AsyncValue<List<SavedRecipe>>> {
final RecipeService _service;
SavedRecipesNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.getSavedRecipes());
}
/// Saves [recipe] and reloads the list. Returns the saved record or null on error.
Future<SavedRecipe?> save(Recipe recipe) async {
try {
final saved = await _service.saveRecipe(recipe);
await load();
return saved;
} catch (_) {
return null;
}
}
/// Removes the recipe with [id] optimistically and reverts on error.
Future<bool> delete(String id) async {
final previous = state;
state = state.whenData(
(list) => list.where((r) => r.id != id).toList(),
);
try {
await _service.deleteSavedRecipe(id);
return true;
} catch (_) {
state = previous;
return false;
}
}
/// Returns true if any saved recipe has the same title.
bool isSaved(String title) {
return state.whenOrNull(
data: (list) => list.any((r) => r.title == title),
) ??
false;
}
/// Returns the saved recipe ID for the given title, or null.
String? savedId(String title) {
return state.whenOrNull(
data: (list) {
try {
return list.firstWhere((r) => r.title == title).id;
} catch (_) {
return null;
}
},
);
}
}
final savedRecipesProvider = StateNotifierProvider<SavedRecipesNotifier,
AsyncValue<List<SavedRecipe>>>((ref) {
return SavedRecipesNotifier(ref.read(recipeServiceProvider));
});

View File

@@ -0,0 +1,36 @@
import '../../core/api/api_client.dart';
import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart';
class RecipeService {
final ApiClient _apiClient;
RecipeService(this._apiClient);
Future<List<Recipe>> getRecommendations({int count = 5}) async {
final data = await _apiClient.getList(
'/recommendations',
params: {'count': '$count'},
);
return data
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<SavedRecipe>> getSavedRecipes() async {
final data = await _apiClient.getList('/saved-recipes');
return data
.map((e) => SavedRecipe.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<SavedRecipe> saveRecipe(Recipe recipe) async {
final body = recipe.toJson()..['source'] = 'ai';
final response = await _apiClient.post('/saved-recipes', data: body);
return SavedRecipe.fromJson(response);
}
Future<void> deleteSavedRecipe(String id) async {
await _apiClient.deleteVoid('/saved-recipes/$id');
}
}

View File

@@ -1,13 +1,52 @@
import 'package:flutter/material.dart';
class RecipesScreen extends StatelessWidget {
import 'recommendations_screen.dart';
import 'saved_recipes_screen.dart';
/// Root screen for the Recipes tab — two sub-tabs: Recommendations and Saved.
class RecipesScreen extends StatefulWidget {
const RecipesScreen({super.key});
@override
State<RecipesScreen> createState() => _RecipesScreenState();
}
class _RecipesScreenState extends State<RecipesScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Рецепты')),
body: const Center(child: Text('Раздел в разработке')),
appBar: AppBar(
title: const Text('Рецепты'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Рекомендации'),
Tab(text: 'Сохранённые'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
RecommendationsScreen(),
SavedRecipesScreen(),
],
),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/models/recipe.dart';
import 'recipe_provider.dart';
import 'widgets/recipe_card.dart';
import 'widgets/skeleton_card.dart';
class RecommendationsScreen extends ConsumerWidget {
const RecommendationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(recommendationsProvider);
return Scaffold(
// AppBar is owned by RecipesScreen (tab host), but we add the
// refresh action via a floating action button inside this child.
body: state.when(
loading: () => _SkeletonList(),
error: (err, _) => _ErrorView(
message: err.toString(),
onRetry: () =>
ref.read(recommendationsProvider.notifier).load(),
),
data: (recipes) => _RecipeList(recipes: recipes),
),
floatingActionButton: FloatingActionButton(
heroTag: 'refresh_recommendations',
tooltip: 'Обновить рекомендации',
onPressed: state is AsyncLoading
? null
: () => ref.read(recommendationsProvider.notifier).load(),
child: state is AsyncLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
),
);
}
}
// ---------------------------------------------------------------------------
// Skeleton list — shown while AI is generating recipes
// ---------------------------------------------------------------------------
class _SkeletonList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: 3,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (_, __) => const SkeletonCard(),
);
}
}
// ---------------------------------------------------------------------------
// Loaded recipe list
// ---------------------------------------------------------------------------
class _RecipeList extends StatelessWidget {
final List<Recipe> recipes;
const _RecipeList({required this.recipes});
@override
Widget build(BuildContext context) {
if (recipes.isEmpty) {
return const Center(
child: Text('Нет рекомендаций. Нажмите ↻ чтобы получить рецепты.'),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), // room for FAB
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final recipe = recipes[index];
return RecipeCard(
recipe: recipe,
onTap: () => context.push(
'/recipe-detail',
extra: recipe,
),
);
},
);
}
}
// ---------------------------------------------------------------------------
// Error view
// ---------------------------------------------------------------------------
class _ErrorView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorView({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text(
'Не удалось получить рецепты',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
message,
style: const TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Попробовать снова'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,271 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_colors.dart';
import '../../shared/models/saved_recipe.dart';
import 'recipe_provider.dart';
class SavedRecipesScreen extends ConsumerWidget {
const SavedRecipesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(savedRecipesProvider);
return state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text('Не удалось загрузить сохранённые рецепты'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.read(savedRecipesProvider.notifier).load(),
child: const Text('Повторить'),
),
],
),
),
data: (recipes) => recipes.isEmpty
? const _EmptyState()
: _SavedList(recipes: recipes),
);
}
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
class _SavedList extends StatelessWidget {
final List<SavedRecipe> recipes;
const _SavedList({required this.recipes});
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_SavedRecipeItem(recipe: recipes[index]),
);
}
}
// ---------------------------------------------------------------------------
// Single item with swipe-to-delete
// ---------------------------------------------------------------------------
class _SavedRecipeItem extends ConsumerWidget {
final SavedRecipe recipe;
const _SavedRecipeItem({required this.recipe});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: ValueKey(recipe.id),
direction: DismissDirection.endToStart,
background: _DeleteBackground(),
confirmDismiss: (_) => _confirmDelete(context),
onDismissed: (_) async {
final ok =
await ref.read(savedRecipesProvider.notifier).delete(recipe.id);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось удалить рецепт')),
);
}
},
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.push('/recipe-detail', extra: recipe),
child: Row(
children: [
// Thumbnail
_Thumbnail(imageUrl: recipe.imageUrl),
// Info
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (recipe.nutrition != null) ...[
const SizedBox(height: 4),
Text(
'${recipe.nutrition!.calories.round()} ккал · '
'${recipe.nutrition!.proteinG.round()} б · '
'${recipe.nutrition!.fatG.round()} ж · '
'${recipe.nutrition!.carbsG.round()} у',
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
if (recipe.prepTimeMin != null ||
recipe.cookTimeMin != null) ...[
const SizedBox(height: 4),
Text(
_timeLabel(recipe.prepTimeMin, recipe.cookTimeMin),
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
],
),
),
),
// Delete button
IconButton(
icon: const Icon(Icons.delete_outline,
color: AppColors.textSecondary),
onPressed: () async {
final confirmed = await _confirmDelete(context);
if (confirmed == true && context.mounted) {
final ok = await ref
.read(savedRecipesProvider.notifier)
.delete(recipe.id);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось удалить рецепт')),
);
}
}
},
),
],
),
),
),
);
}
String _timeLabel(int? prep, int? cook) {
final total = (prep ?? 0) + (cook ?? 0);
return total > 0 ? '$total мин' : '';
}
Future<bool?> _confirmDelete(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить рецепт?'),
content: Text('«${recipe.title}» будет удалён из сохранённых.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Удалить'),
),
],
),
);
}
}
class _Thumbnail extends StatelessWidget {
final String? imageUrl;
const _Thumbnail({this.imageUrl});
@override
Widget build(BuildContext context) {
if (imageUrl == null || imageUrl!.isEmpty) {
return Container(
width: 80,
height: 80,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Icon(Icons.restaurant),
);
}
return CachedNetworkImage(
imageUrl: imageUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(width: 80, height: 80, color: Colors.grey[200]),
errorWidget: (_, __, ___) => Container(
width: 80,
height: 80,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Icon(Icons.restaurant),
),
);
}
}
class _DeleteBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite_border,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Нет сохранённых рецептов',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'Сохраняйте рецепты из рекомендаций,\nнажимая на ♡',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[500]),
),
],
),
),
);
}
}

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