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/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<GoRouter>((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<GoRouter>((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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user