Files
food-ai/client/lib/features/recipes/recipe_detail_screen.dart
dbastrikin 61feb91bba feat: core schema redesign — dishes, structured recipes, cuisines, tags (iteration 7)
Replaces the flat JSONB-based recipe schema with a normalized relational model:

Schema (migrations consolidated to 001_initial_schema + 002_seed_data):
- New: dishes, dish_translations, dish_tags — canonical dish catalog
- New: cuisines, tags, dish_categories with _translations tables + full seed data
- New: recipe_ingredients, recipe_steps with _translations (replaces JSONB blobs)
- New: user_saved_recipes thin bookmark (drops saved_recipes + saved_recipe_translations)
- New: product_ingredients M2M table
- recipes: now a cooking variant of a dish (dish_id FK, no title/JSONB columns)
- recipe_translations: repurposed to per-language notes only
- products: mapping_id → primary_ingredient_id
- menu_items: recipe_id FK → recipes; adds dish_id
- meal_diary: adds dish_id, recipe_id → recipes, portion_g

Backend (Go):
- New packages: internal/cuisine, internal/tag, internal/dish (registry + handler + repo)
- New GET /cuisines, GET /tags (public), GET /dishes, GET /dishes/{id}, GET /recipes/{id}
- recipe, savedrecipe, menu, diary, product, ingredient packages updated for new schema

Flutter:
- New models: Cuisine, Tag; new providers: cuisineNamesProvider, tagNamesProvider
- recipe.dart: RecipeIngredient gains unit_code + effectiveUnit getter
- saved_recipe.dart: thin model, manual fromJson, computed nutrition getter
- diary_entry.dart: adds dishId, recipeId, portionG
- recipe_detail_screen.dart: localized cuisine/tag names via providers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:01:24 +02:00

554 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
);
}
}