Files
food-ai/client/lib/features/recipes/widgets/recipe_card.dart
dbastrikin a0ebd6cc0b feat: apply iOS-style theme and replace removed color constants
Switch AppColors to iOS system palette (007AFF blue, F2F2F7 grouped
background, separator, label hierarchy) and rewrite AppTheme with
iOS-inspired Material 3 tokens (no elevation, negative letter-spacing,
50px buttons, 12px radii). Replace removed primaryLight/accent references
in recipe screens with primary.withValues(alpha:0.15) and primary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:54:54 +02:00

264 lines
7.7 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/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,
);
}