diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 5912691..a8a63cb 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -18,13 +18,9 @@ import '../../features/scan/recognition_confirm_screen.dart'; import '../../features/scan/recognition_service.dart'; import '../../features/menu/menu_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/products/user_product_provider.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. class _RouterNotifier extends ChangeNotifier { @@ -104,20 +100,6 @@ final routerProvider = Provider((ref) { path: '/onboarding', 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. GoRoute( path: '/products/add', @@ -155,8 +137,6 @@ final routerProvider = Provider((ref) { path: '/products', builder: (_, __) => const ProductsScreen()), GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()), - GoRoute( - path: '/recipes', builder: (_, __) => const RecipesScreen()), GoRoute( 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 { final Widget child; @@ -195,7 +163,6 @@ class MainShell extends ConsumerWidget { '/home', '/products', '/menu', - '/recipes', '/profile', ]; @@ -234,10 +201,6 @@ class MainShell extends ConsumerWidget { icon: const Icon(Icons.calendar_month), label: l10n.menu, ), - BottomNavigationBarItem( - icon: const Icon(Icons.menu_book), - label: l10n.navRecipes, - ), BottomNavigationBarItem( icon: const Icon(Icons.person), label: l10n.profileTitle, diff --git a/client/lib/features/recipes/recipe_detail_screen.dart b/client/lib/features/recipes/recipe_detail_screen.dart deleted file mode 100644 index a3a344b..0000000 --- a/client/lib/features/recipes/recipe_detail_screen.dart +++ /dev/null @@ -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 createState() => _RecipeDetailScreenState(); -} - -class _RecipeDetailScreenState extends ConsumerState { - 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 get _ingredients => - widget.recipe?.ingredients ?? widget.saved!.ingredients; - List get _steps => - widget.recipe?.steps ?? widget.saved!.steps; - List 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 _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 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 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 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, - ), - ), - ); - } -} diff --git a/client/lib/features/recipes/recipe_provider.dart b/client/lib/features/recipes/recipe_provider.dart deleted file mode 100644 index 7c7cb68..0000000 --- a/client/lib/features/recipes/recipe_provider.dart +++ /dev/null @@ -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((ref) { - return RecipeService(ref.read(apiClientProvider)); -}); - -// --------------------------------------------------------------------------- -// Recommendations -// --------------------------------------------------------------------------- - -class RecommendationsNotifier - extends StateNotifier>> { - final RecipeService _service; - - RecommendationsNotifier(this._service) : super(const AsyncValue.loading()) { - load(); - } - - Future load({int count = 5}) async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard( - () => _service.getRecommendations(count: count), - ); - } -} - -final recommendationsProvider = StateNotifierProvider>>((ref) { - return RecommendationsNotifier(ref.read(recipeServiceProvider)); -}); - -// --------------------------------------------------------------------------- -// Saved recipes -// --------------------------------------------------------------------------- - -class SavedRecipesNotifier - extends StateNotifier>> { - final RecipeService _service; - - SavedRecipesNotifier(this._service) : super(const AsyncValue.loading()) { - load(); - } - - Future 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 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 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>>((ref) { - return SavedRecipesNotifier(ref.read(recipeServiceProvider)); -}); diff --git a/client/lib/features/recipes/recipe_service.dart b/client/lib/features/recipes/recipe_service.dart deleted file mode 100644 index 2845244..0000000 --- a/client/lib/features/recipes/recipe_service.dart +++ /dev/null @@ -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> getRecommendations({int count = 5}) async { - final data = await _apiClient.getList( - '/recommendations', - params: {'count': '$count'}, - ); - return data - .map((e) => Recipe.fromJson(e as Map)) - .toList(); - } - - Future> getSavedRecipes() async { - final data = await _apiClient.getList('/saved-recipes'); - return data - .map((e) => SavedRecipe.fromJson(e as Map)) - .toList(); - } - - Future saveRecipe(Recipe recipe) async { - final body = recipe.toJson()..['source'] = 'ai'; - final response = await _apiClient.post('/saved-recipes', data: body); - return SavedRecipe.fromJson(response); - } - - Future deleteSavedRecipe(String id) async { - await _apiClient.deleteVoid('/saved-recipes/$id'); - } -} diff --git a/client/lib/features/recipes/recipes_screen.dart b/client/lib/features/recipes/recipes_screen.dart deleted file mode 100644 index 20304b9..0000000 --- a/client/lib/features/recipes/recipes_screen.dart +++ /dev/null @@ -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 createState() => _RecipesScreenState(); -} - -class _RecipesScreenState extends State - 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(), - ], - ), - ); - } -} diff --git a/client/lib/features/recipes/recommendations_screen.dart b/client/lib/features/recipes/recommendations_screen.dart deleted file mode 100644 index 08cd754..0000000 --- a/client/lib/features/recipes/recommendations_screen.dart +++ /dev/null @@ -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 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('Попробовать снова'), - ), - ], - ), - ), - ); - } -} diff --git a/client/lib/features/recipes/saved_recipes_screen.dart b/client/lib/features/recipes/saved_recipes_screen.dart deleted file mode 100644 index fa0ffef..0000000 --- a/client/lib/features/recipes/saved_recipes_screen.dart +++ /dev/null @@ -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 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 _confirmDelete(BuildContext context) { - return showDialog( - 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]), - ), - ], - ), - ), - ); - } -} diff --git a/client/lib/features/recipes/widgets/recipe_card.dart b/client/lib/features/recipes/widgets/recipe_card.dart deleted file mode 100644 index 9cdf317..0000000 --- a/client/lib/features/recipes/widgets/recipe_card.dart +++ /dev/null @@ -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 _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, - ); -} diff --git a/client/lib/features/recipes/widgets/skeleton_card.dart b/client/lib/features/recipes/widgets/skeleton_card.dart deleted file mode 100644 index fae11f9..0000000 --- a/client/lib/features/recipes/widgets/skeleton_card.dart +++ /dev/null @@ -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 createState() => _SkeletonCardState(); -} - -class _SkeletonCardState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _ctrl; - late final Animation _anim; - - @override - void initState() { - super.initState(); - _ctrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(reverse: true); - _anim = Tween(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), - ), - ); -} diff --git a/docs/TODO.md b/docs/TODO.md index f68e7aa..ca9705a 100644 --- a/docs/TODO.md +++ b/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` + +--- + ## База данных рецептов и нутриентов ### Верифицированная база нутриентов