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