Files
food-ai/client/lib/features/recipes/recipe_detail_screen.dart
dbastrikin 55d01400b0 feat: dynamic units table with localized names via GET /units
- Add units + unit_translations tables with FK constraints on products and ingredient_mappings
- Normalize products.unit from Russian strings (г, кг) to English codes (g, kg)
- Load units at startup (in-memory registry) and serve via GET /units (language-aware)
- Replace hardcoded _units lists and _mapUnit() functions in Flutter with unitsProvider FutureProvider
- Re-fetches automatically when language changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:15:33 +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/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 StatelessWidget {
final int? prepTimeMin;
final int? cookTimeMin;
final String? difficulty;
final String? cuisine;
final int? servings;
const _MetaChips({
this.prepTimeMin,
this.cookTimeMin,
this.difficulty,
this.cuisine,
this.servings,
});
@override
Widget build(BuildContext context) {
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (totalMin > 0)
_Chip(icon: Icons.access_time, label: '$totalMin мин'),
if (difficulty != null)
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
if (cuisine != null)
_Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)),
if (servings != null)
_Chip(icon: Icons.people, label: '$servings порц.'),
],
);
}
String _difficultyLabel(String d) => switch (d) {
'easy' => 'Легко',
'medium' => 'Средне',
'hard' => 'Сложно',
_ => d,
};
String _cuisineLabel(String c) => switch (c) {
'russian' => 'Русская',
'asian' => 'Азиатская',
'european' => 'Европейская',
'mediterranean' => 'Средиземноморская',
'american' => 'Американская',
_ => 'Другая',
};
}
class _Chip extends StatelessWidget {
final IconData icon;
final String label;
const _Chip({required this.icon, required this.label});
@override
Widget build(BuildContext context) => Chip(
avatar: Icon(icon, size: 14),
label: Text(label, style: const TextStyle(fontSize: 12)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
);
}
class _NutritionCard extends StatelessWidget {
final NutritionInfo nutrition;
const _NutritionCard({required this.nutrition});
@override
Widget build(BuildContext context) {
return Card(
color: AppColors.primary.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'КБЖУ на порцию',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Tooltip(
message: 'Значения рассчитаны приблизительно с помощью ИИ',
child: Text(
'',
style: TextStyle(
color: AppColors.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 StatelessWidget {
final List<String> tags;
const _TagsRow({required this.tags});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 6,
runSpacing: 4,
children: tags
.map(
(t) => Chip(
label: Text(t, style: const TextStyle(fontSize: 11)),
backgroundColor: AppColors.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.unit] ?? ing.unit}',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13),
),
],
),
),
),
],
),
);
}
String _formatAmount(double amount) {
if (amount == amount.truncate()) return amount.toInt().toString();
return amount.toStringAsFixed(1);
}
}
class _StepsSection extends StatelessWidget {
final List<RecipeStep> steps;
const _StepsSection({required this.steps});
@override
Widget build(BuildContext context) {
if (steps.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Приготовление',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
...steps.map((step) => _StepTile(step: step)),
],
),
);
}
}
class _StepTile extends StatelessWidget {
final RecipeStep step;
const _StepTile({required this.step});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Step number badge
Container(
width: 28,
height: 28,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${step.number}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(step.description),
if (step.timerSeconds != null) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.timer_outlined,
size: 14, color: AppColors.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,
),
),
);
}
}