glp-companion/lib/features/meal_planner/presentation/widgets/saved_recipes_tray.dart

228 lines
6.9 KiB
Dart

import 'package:flutter/material.dart';
import '../../domain/entities/recipe.dart';
/// Horizontally scrolling tray of saved recipe cards.
///
/// Each card is draggable onto the weekly calendar's meal slots and
/// carries a "+" affordance that opens the quick-assign modal.
class SavedRecipesTray extends StatelessWidget {
const SavedRecipesTray({
super.key,
required this.recipes,
this.onAssignPressed,
});
/// Saved recipes, newest first.
final List<Recipe> recipes;
/// Called when the card's quick-assign button (or the card itself) is
/// tapped.
final void Function(Recipe recipe)? onAssignPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
'Saved Recipes',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Text(
'${recipes.length}',
style: theme.textTheme.labelMedium
?.copyWith(color: theme.colorScheme.primary),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
height: 148,
child: recipes.isEmpty
? Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Import a recipe above — it will appear here, ready to '
'drag onto the weekly plan.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: recipes.length,
separatorBuilder: (context, index) =>
const SizedBox(width: 12),
itemBuilder: (context, index) {
final recipe = recipes[index];
return _RecipeCard(
recipe: recipe,
onAssignPressed: onAssignPressed == null
? null
: () => onAssignPressed!(recipe),
);
},
),
),
],
);
}
}
/// Draggable card showing a recipe thumbnail, title, and protein badge.
class _RecipeCard extends StatelessWidget {
const _RecipeCard({required this.recipe, this.onAssignPressed});
final Recipe recipe;
final VoidCallback? onAssignPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final protein = recipe.macrosPerServing.proteinGrams;
final card = SizedBox(
width: 180,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onAssignPressed,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: recipe.imageUrl == null
? _ThumbnailPlaceholder(theme: theme)
: Image.network(
recipe.imageUrl!,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_ThumbnailPlaceholder(theme: theme),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Row(
children: [
_ProteinBadge(proteinGrams: protein),
const Spacer(),
if (onAssignPressed != null)
Icon(
Icons.add_circle_outline,
size: 20,
color: theme.colorScheme.primary,
),
],
),
],
),
),
],
),
),
),
);
return Draggable<Recipe>(
data: recipe,
feedback: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
recipe.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
),
childWhenDragging: Opacity(opacity: 0.4, child: card),
child: card,
);
}
}
/// Fallback tile when a recipe has no (loadable) thumbnail.
class _ThumbnailPlaceholder extends StatelessWidget {
const _ThumbnailPlaceholder({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Container(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.restaurant_menu,
size: 28,
color: theme.colorScheme.outline,
),
),
);
}
}
/// Compact green badge showing the protein contribution of a recipe.
class _ProteinBadge extends StatelessWidget {
const _ProteinBadge({required this.proteinGrams});
final double proteinGrams;
@override
Widget build(BuildContext context) {
final rounded = proteinGrams.roundToDouble();
final label = rounded == proteinGrams
? '${rounded.toStringAsFixed(0)}g P'
: '${proteinGrams.toStringAsFixed(1)}g P';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.green.shade800,
),
),
);
}
}