feat: remove recipes section, add redevelopment TODO
The recipes tab (recommendations + saved recipes + detail screen) is removed from the app UI and all feature files are deleted. The section will be redesigned from scratch. - Remove /recipes and /recipe-detail routes from app_router.dart - Remove Recipes tab from BottomNavigationBar - Delete client/lib/features/recipes/ entirely - Keep shared/models/recipe.dart and saved_recipe.dart for later reuse - Add "Раздел рецептов (переработка с нуля)" section to docs/TODO.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,13 +18,9 @@ import '../../features/scan/recognition_confirm_screen.dart';
|
|||||||
import '../../features/scan/recognition_service.dart';
|
import '../../features/scan/recognition_service.dart';
|
||||||
import '../../features/menu/menu_screen.dart';
|
import '../../features/menu/menu_screen.dart';
|
||||||
import '../../features/menu/shopping_list_screen.dart';
|
import '../../features/menu/shopping_list_screen.dart';
|
||||||
import '../../features/recipes/recipe_detail_screen.dart';
|
|
||||||
import '../../features/recipes/recipes_screen.dart';
|
|
||||||
import '../../features/profile/profile_screen.dart';
|
import '../../features/profile/profile_screen.dart';
|
||||||
import '../../features/products/user_product_provider.dart';
|
import '../../features/products/user_product_provider.dart';
|
||||||
import '../../features/scan/recognition_history_screen.dart';
|
import '../../features/scan/recognition_history_screen.dart';
|
||||||
import '../../shared/models/recipe.dart';
|
|
||||||
import '../../shared/models/saved_recipe.dart';
|
|
||||||
|
|
||||||
// Notifies GoRouter when auth state or profile state changes.
|
// Notifies GoRouter when auth state or profile state changes.
|
||||||
class _RouterNotifier extends ChangeNotifier {
|
class _RouterNotifier extends ChangeNotifier {
|
||||||
@@ -104,20 +100,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/onboarding',
|
path: '/onboarding',
|
||||||
builder: (_, __) => const OnboardingScreen(),
|
builder: (_, __) => const OnboardingScreen(),
|
||||||
),
|
),
|
||||||
// Full-screen recipe detail — shown without the bottom navigation bar.
|
|
||||||
GoRoute(
|
|
||||||
path: '/recipe-detail',
|
|
||||||
builder: (context, state) {
|
|
||||||
final extra = state.extra;
|
|
||||||
if (extra is Recipe) {
|
|
||||||
return RecipeDetailScreen(recipe: extra);
|
|
||||||
}
|
|
||||||
if (extra is SavedRecipe) {
|
|
||||||
return RecipeDetailScreen(saved: extra);
|
|
||||||
}
|
|
||||||
return const _InvalidRoute();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Add product — shown without the bottom navigation bar.
|
// Add product — shown without the bottom navigation bar.
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/products/add',
|
path: '/products/add',
|
||||||
@@ -155,8 +137,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/products',
|
path: '/products',
|
||||||
builder: (_, __) => const ProductsScreen()),
|
builder: (_, __) => const ProductsScreen()),
|
||||||
GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()),
|
GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()),
|
||||||
GoRoute(
|
|
||||||
path: '/recipes', builder: (_, __) => const RecipesScreen()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile', builder: (_, __) => const ProfileScreen()),
|
path: '/profile', builder: (_, __) => const ProfileScreen()),
|
||||||
],
|
],
|
||||||
@@ -174,18 +154,6 @@ class _SplashScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InvalidRoute extends StatelessWidget {
|
|
||||||
const _InvalidRoute();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
|
||||||
});
|
|
||||||
return const Scaffold(body: SizedBox.shrink());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MainShell extends ConsumerWidget {
|
class MainShell extends ConsumerWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@@ -195,7 +163,6 @@ class MainShell extends ConsumerWidget {
|
|||||||
'/home',
|
'/home',
|
||||||
'/products',
|
'/products',
|
||||||
'/menu',
|
'/menu',
|
||||||
'/recipes',
|
|
||||||
'/profile',
|
'/profile',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -234,10 +201,6 @@ class MainShell extends ConsumerWidget {
|
|||||||
icon: const Icon(Icons.calendar_month),
|
icon: const Icon(Icons.calendar_month),
|
||||||
label: l10n.menu,
|
label: l10n.menu,
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.menu_book),
|
|
||||||
label: l10n.navRecipes,
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: const Icon(Icons.person),
|
icon: const Icon(Icons.person),
|
||||||
label: l10n.profileTitle,
|
label: l10n.profileTitle,
|
||||||
|
|||||||
@@ -1,553 +0,0 @@
|
|||||||
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/locale/cuisine_provider.dart';
|
|
||||||
import '../../core/locale/tag_provider.dart';
|
|
||||||
import '../../core/locale/unit_provider.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.primary.withValues(alpha: 0.15),
|
|
||||||
child: const Center(child: Icon(Icons.restaurant, size: 64)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MetaChips extends ConsumerWidget {
|
|
||||||
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, WidgetRef ref) {
|
|
||||||
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
|
|
||||||
final cuisineNames = ref.watch(cuisineNamesProvider).valueOrNull ?? {};
|
|
||||||
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: cuisineNames[cuisine!] ?? cuisine!),
|
|
||||||
if (servings != null)
|
|
||||||
_Chip(icon: Icons.people, label: '$servings порц.'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _difficultyLabel(String d) => switch (d) {
|
|
||||||
'easy' => 'Легко',
|
|
||||||
'medium' => 'Средне',
|
|
||||||
'hard' => 'Сложно',
|
|
||||||
_ => d,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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.primary,
|
|
||||||
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 ConsumerWidget {
|
|
||||||
final List<String> tags;
|
|
||||||
|
|
||||||
const _TagsRow({required this.tags});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final tagNames = ref.watch(tagNamesProvider).valueOrNull ?? {};
|
|
||||||
return Wrap(
|
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 4,
|
|
||||||
children: tags
|
|
||||||
.map(
|
|
||||||
(t) => Chip(
|
|
||||||
label: Text(
|
|
||||||
tagNames[t] ?? t,
|
|
||||||
style: const TextStyle(fontSize: 11),
|
|
||||||
),
|
|
||||||
backgroundColor: AppColors.primary.withValues(alpha: 0.15),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _IngredientsSection extends ConsumerWidget {
|
|
||||||
final List<RecipeIngredient> ingredients;
|
|
||||||
|
|
||||||
const _IngredientsSection({required this.ingredients});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
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)} ${ref.watch(unitsProvider).valueOrNull?[ing.effectiveUnit] ?? ing.effectiveUnit}',
|
|
||||||
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.primary),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatTimer(step.timerSeconds!),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.primary, 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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));
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
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('Рецепты'),
|
|
||||||
bottom: TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: 'Рекомендации'),
|
|
||||||
Tab(text: 'Сохранённые'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: const [
|
|
||||||
RecommendationsScreen(),
|
|
||||||
SavedRecipesScreen(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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('Попробовать снова'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
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.primary.withValues(alpha: 0.15),
|
|
||||||
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.primary.withValues(alpha: 0.15),
|
|
||||||
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]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
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.primary.withValues(alpha: 0.15),
|
|
||||||
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.primary.withValues(alpha: 0.15),
|
|
||||||
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.primary)),
|
|
||||||
_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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
19
docs/TODO.md
19
docs/TODO.md
@@ -4,6 +4,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Раздел рецептов (переработка с нуля)
|
||||||
|
|
||||||
|
Текущий раздел рецептов удалён из приложения — он был сырым и требует переосмысления.
|
||||||
|
Модели данных (`Recipe`, `SavedRecipe`) сохранены в `client/lib/shared/models/`.
|
||||||
|
|
||||||
|
Что должен включать новый раздел:
|
||||||
|
|
||||||
|
- **Лента / каталог** — просмотр рецептов с фильтрами (кухня, сложность, время, КБЖУ, теги)
|
||||||
|
- **Поиск** — full-text по названию и ингредиентам (PostgreSQL tsvector, индексы уже в схеме)
|
||||||
|
- **"Что можно приготовить"** — поиск рецептов по продуктам из холодильника (mapping_id)
|
||||||
|
- **Сохранённые рецепты** — личный список, доступен оффлайн
|
||||||
|
- **Детальный экран** — пошаговый рецепт, КБЖУ, изображение, рейтинг
|
||||||
|
- **Интеграция с меню** — добавить рецепт в план питания прямо из карточки
|
||||||
|
- **Интеграция с дневником** — записать приём пищи по рецепту
|
||||||
|
- **AI-рекомендации** — персонализированные предложения на основе предпочтений и продуктов
|
||||||
|
- **Рейтинги и отзывы** — поля `avg_rating`, `review_count` уже есть в схеме `recipes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## База данных рецептов и нутриентов
|
## База данных рецептов и нутриентов
|
||||||
|
|
||||||
### Верифицированная база нутриентов
|
### Верифицированная база нутриентов
|
||||||
|
|||||||
Reference in New Issue
Block a user