Files
food-ai/client/lib/features/home/home_screen.dart
dbastrikin a32d2960c4 feat: show dish recognition result as bottom sheet on home screen
Remove "Определить блюдо" from ScanScreen and the /scan/dish route.
The + button on each meal card now triggers dish recognition inline —
picks image, shows loading dialog, then presents DishResultSheet as a
modal bottom sheet. After adding to diary the sheet closes and the user
stays on home.

Also fix Navigator.pop crash: showDialog uses the root navigator by
default, so capture Navigator.of(context, rootNavigator: true) before
the async gap and use it to close the loading dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 16:37:00 +02:00

1147 lines
37 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 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/theme/app_colors.dart';
import '../../shared/models/diary_entry.dart';
import '../../shared/models/home_summary.dart';
import '../../shared/models/meal_type.dart';
import '../menu/menu_provider.dart';
import '../profile/profile_provider.dart';
import '../scan/dish_result_screen.dart';
import '../scan/recognition_service.dart';
import 'home_provider.dart';
// ── Root screen ───────────────────────────────────────────────
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final homeSummaryState = ref.watch(homeProvider);
final profile = ref.watch(profileProvider).valueOrNull;
final userName = profile?.name;
final goalType = profile?.goal;
final dailyGoal = profile?.dailyCalories ?? 0;
final userMealTypes = profile?.mealTypes ?? const ['breakfast', 'lunch', 'dinner'];
final selectedDate = ref.watch(selectedDateProvider);
final dateString = formatDateForDiary(selectedDate);
final diaryState = ref.watch(diaryProvider(dateString));
final entries = diaryState.valueOrNull ?? [];
final loggedCalories = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.calories ?? 0));
final loggedProtein = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.proteinG ?? 0));
final loggedFat = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.fatG ?? 0));
final loggedCarbs = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.carbsG ?? 0));
final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? [];
final recommendations = homeSummaryState.valueOrNull?.recommendations ?? [];
return Scaffold(
body: RefreshIndicator(
onRefresh: () async {
ref.read(homeProvider.notifier).load();
ref.invalidate(diaryProvider(dateString));
},
child: CustomScrollView(
slivers: [
_AppBar(userName: userName),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
sliver: SliverList(
delegate: SliverChildListDelegate([
const SizedBox(height: 12),
_DateSelector(
selectedDate: selectedDate,
onDateSelected: (date) =>
ref.read(selectedDateProvider.notifier).state = date,
),
const SizedBox(height: 16),
_CaloriesCard(
loggedCalories: loggedCalories,
dailyGoal: dailyGoal,
goalType: goalType,
),
const SizedBox(height: 12),
_MacrosRow(
proteinG: loggedProtein,
fatG: loggedFat,
carbsG: loggedCarbs,
),
const SizedBox(height: 16),
_DailyMealsSection(
mealTypeIds: userMealTypes,
entries: entries,
dateString: dateString,
),
if (expiringSoon.isNotEmpty) ...[
const SizedBox(height: 16),
_ExpiringBanner(items: expiringSoon),
],
const SizedBox(height: 16),
_QuickActionsRow(date: dateString),
if (recommendations.isNotEmpty) ...[
const SizedBox(height: 20),
_SectionTitle('Рекомендуем приготовить'),
const SizedBox(height: 12),
_RecommendationsRow(recipes: recommendations),
],
]),
),
),
],
),
),
);
}
}
// ── App bar ───────────────────────────────────────────────────
class _AppBar extends StatelessWidget {
final String? userName;
const _AppBar({this.userName});
String get _greetingBase {
final hour = DateTime.now().hour;
if (hour < 12) return 'Доброе утро';
if (hour < 18) return 'Добрый день';
return 'Добрый вечер';
}
String get _greeting {
final name = userName;
if (name != null && name.isNotEmpty) return '$_greetingBase, $name!';
return _greetingBase;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SliverAppBar(
pinned: false,
floating: true,
title: Text(_greeting, style: theme.textTheme.titleMedium),
);
}
}
// ── Date selector ─────────────────────────────────────────────
class _DateSelector extends StatefulWidget {
final DateTime selectedDate;
final ValueChanged<DateTime> onDateSelected;
const _DateSelector({
required this.selectedDate,
required this.onDateSelected,
});
@override
State<_DateSelector> createState() => _DateSelectorState();
}
class _DateSelectorState extends State<_DateSelector> {
static const _weekDayShort = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
static const _monthNames = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
];
// Total days available in the past (index 0 = today, index N-1 = oldest)
static const _totalDays = 365;
static const _pillWidth = 48.0;
static const _pillSpacing = 6.0;
late final ScrollController _scrollController;
String _formatSelectedDate(DateTime date) {
final now = DateTime.now();
final dayName = _weekDayShort[date.weekday - 1];
final month = _monthNames[date.month - 1];
final yearSuffix = date.year != now.year ? ' ${date.year}' : '';
return '$dayName, ${date.day} $month$yearSuffix';
}
// Index in the reversed list: 0 = today, 1 = yesterday, …
int _indexForDate(DateTime date) {
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
final dateNormalized = DateTime(date.year, date.month, date.day);
return todayNormalized.difference(dateNormalized).inDays.clamp(0, _totalDays - 1);
}
double _offsetForIndex(int index) => index * (_pillWidth + _pillSpacing);
void _selectPreviousDay() {
final previousDay = widget.selectedDate.subtract(const Duration(days: 1));
final previousDayNormalized =
DateTime(previousDay.year, previousDay.month, previousDay.day);
final today = DateTime.now();
final oldestAllowed = DateTime(today.year, today.month, today.day)
.subtract(const Duration(days: _totalDays - 1));
if (!previousDayNormalized.isBefore(oldestAllowed)) {
widget.onDateSelected(previousDayNormalized);
}
}
void _selectNextDay() {
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
final nextDay = widget.selectedDate.add(const Duration(days: 1));
final nextDayNormalized =
DateTime(nextDay.year, nextDay.month, nextDay.day);
if (!nextDayNormalized.isAfter(todayNormalized)) {
widget.onDateSelected(nextDayNormalized);
}
}
void _jumpToToday() {
final today = DateTime.now();
widget.onDateSelected(DateTime(today.year, today.month, today.day));
if (_scrollController.hasClients) {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void didUpdateWidget(_DateSelector oldWidget) {
super.didUpdateWidget(oldWidget);
final oldIndex = _indexForDate(oldWidget.selectedDate);
final newIndex = _indexForDate(widget.selectedDate);
if (oldIndex != newIndex && _scrollController.hasClients) {
_scrollController.animateTo(
_offsetForIndex(newIndex),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
final selectedNormalized = DateTime(
widget.selectedDate.year, widget.selectedDate.month, widget.selectedDate.day);
final isToday = selectedNormalized == todayNormalized;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header row ──────────────────────────────────────────
SizedBox(
height: 36,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
iconSize: 20,
visualDensity: VisualDensity.compact,
onPressed: _selectPreviousDay,
),
Expanded(
child: Text(
_formatSelectedDate(widget.selectedDate),
style: theme.textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
),
if (!isToday)
TextButton(
onPressed: _jumpToToday,
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Text(
'Сегодня',
style: theme.textTheme.labelMedium
?.copyWith(color: theme.colorScheme.primary),
),
)
else
IconButton(
icon: const Icon(Icons.chevron_right),
iconSize: 20,
visualDensity: VisualDensity.compact,
onPressed: null,
),
],
),
),
const SizedBox(height: 8),
// ── Day strip ────────────────────────────────────────────
SizedBox(
height: 56,
// reverse: true → index 0 (today) sits at the right edge
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
reverse: true,
itemCount: _totalDays,
separatorBuilder: (_, __) => const SizedBox(width: _pillSpacing),
itemBuilder: (listContext, index) {
final date = todayNormalized.subtract(Duration(days: index));
final isSelected = date == selectedNormalized;
final isDayToday = date == todayNormalized;
return GestureDetector(
onTap: () => widget.onDateSelected(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: _pillWidth,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_weekDayShort[date.weekday - 1],
style: theme.textTheme.labelSmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
'${date.day}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isSelected || isDayToday
? FontWeight.w700
: FontWeight.normal,
color: isSelected
? theme.colorScheme.onPrimary
: isDayToday
? theme.colorScheme.primary
: null,
),
),
],
),
),
);
},
),
),
],
);
}
}
// ── Calories card ─────────────────────────────────────────────
class _CaloriesCard extends StatelessWidget {
final double loggedCalories;
final int dailyGoal;
final String? goalType;
const _CaloriesCard({
required this.loggedCalories,
required this.dailyGoal,
required this.goalType,
});
@override
Widget build(BuildContext context) {
if (dailyGoal == 0) return const SizedBox.shrink();
final theme = Theme.of(context);
final logged = loggedCalories.toInt();
final rawProgress = dailyGoal > 0 ? loggedCalories / dailyGoal : 0.0;
final isOverGoal = rawProgress > 1.0;
final ringColor = _ringColorFor(rawProgress, goalType);
final String secondaryLabel;
final Color secondaryColor;
if (isOverGoal) {
final overBy = (loggedCalories - dailyGoal).toInt();
secondaryLabel = '+$overBy перебор';
secondaryColor = AppColors.error;
} else {
final remaining = (dailyGoal - loggedCalories).toInt();
secondaryLabel = 'осталось $remaining';
secondaryColor = AppColors.textSecondary;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
SizedBox(
width: 160,
height: 160,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: const Size(160, 160),
painter: _CalorieRingPainter(
rawProgress: rawProgress,
ringColor: ringColor,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$logged',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: ringColor,
),
),
Text(
'ккал',
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
'цель: $dailyGoal',
style: theme.textTheme.labelSmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
],
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CalorieStat(
label: 'Потреблено',
value: '$logged ккал',
valueColor: ringColor,
),
const SizedBox(height: 12),
_CalorieStat(
label: isOverGoal ? 'Превышение' : 'Осталось',
value: secondaryLabel,
valueColor: secondaryColor,
),
const SizedBox(height: 12),
_CalorieStat(
label: 'Цель',
value: '$dailyGoal ккал',
valueColor: AppColors.textPrimary,
),
],
),
),
],
),
),
);
}
}
class _CalorieStat extends StatelessWidget {
final String label;
final String value;
final Color valueColor;
const _CalorieStat({
required this.label,
required this.value,
required this.valueColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style:
theme.textTheme.labelSmall?.copyWith(color: AppColors.textSecondary),
),
const SizedBox(height: 2),
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: valueColor,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
// ── Macros row ────────────────────────────────────────────────
class _MacrosRow extends StatelessWidget {
final double proteinG;
final double fatG;
final double carbsG;
const _MacrosRow({
required this.proteinG,
required this.fatG,
required this.carbsG,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _MacroChip(
label: 'Белки',
value: '${proteinG.toStringAsFixed(1)} г',
color: Colors.blue,
),
),
const SizedBox(width: 8),
Expanded(
child: _MacroChip(
label: 'Жиры',
value: '${fatG.toStringAsFixed(1)} г',
color: Colors.orange,
),
),
const SizedBox(width: 8),
Expanded(
child: _MacroChip(
label: 'Углеводы',
value: '${carbsG.toStringAsFixed(1)} г',
color: Colors.green,
),
),
],
);
}
}
class _MacroChip extends StatelessWidget {
final String label;
final String value;
final Color color;
const _MacroChip({
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
// ── Ring colour logic ──────────────────────────────────────────
Color _ringColorFor(double rawProgress, String? goalType) {
switch (goalType) {
case 'lose':
if (rawProgress >= 1.10) return AppColors.error;
if (rawProgress >= 0.95) return AppColors.warning;
if (rawProgress >= 0.75) return AppColors.success;
return AppColors.primary;
case 'maintain':
if (rawProgress >= 1.25) return AppColors.error;
if (rawProgress >= 1.11) return AppColors.warning;
if (rawProgress >= 0.90) return AppColors.success;
if (rawProgress >= 0.70) return AppColors.warning;
return AppColors.primary;
case 'gain':
if (rawProgress < 0.60) return AppColors.error;
if (rawProgress < 0.85) return AppColors.warning;
if (rawProgress <= 1.15) return AppColors.success;
return AppColors.primary;
default:
return AppColors.primary;
}
}
// ── Ring painter ───────────────────────────────────────────────
class _CalorieRingPainter extends CustomPainter {
final double rawProgress;
final Color ringColor;
const _CalorieRingPainter({
required this.rawProgress,
required this.ringColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - 20) / 2;
const strokeWidth = 10.0;
const overflowStrokeWidth = 6.0;
const startAngle = -math.pi / 2;
final trackPaint = Paint()
..color = AppColors.separator.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, trackPaint);
final clampedProgress = rawProgress.clamp(0.0, 1.0);
if (clampedProgress > 0) {
final arcPaint = Paint()
..color = ringColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
clampedProgress * 2 * math.pi,
false,
arcPaint,
);
}
if (rawProgress > 1.0) {
final overflowProgress = rawProgress - 1.0;
final overflowPaint = Paint()
..color = ringColor.withValues(alpha: 0.70)
..style = PaintingStyle.stroke
..strokeWidth = overflowStrokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
overflowProgress.clamp(0.0, 1.0) * 2 * math.pi,
false,
overflowPaint,
);
}
}
@override
bool shouldRepaint(_CalorieRingPainter oldDelegate) =>
oldDelegate.rawProgress != rawProgress ||
oldDelegate.ringColor != ringColor;
}
// ── Daily meals section ───────────────────────────────────────
class _DailyMealsSection extends ConsumerWidget {
final List<String> mealTypeIds;
final List<DiaryEntry> entries;
final String dateString;
const _DailyMealsSection({
required this.mealTypeIds,
required this.entries,
required this.dateString,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Приёмы пищи', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...mealTypeIds.map((mealTypeId) {
final mealTypeOption = mealTypeById(mealTypeId);
if (mealTypeOption == null) return const SizedBox.shrink();
final mealEntries = entries
.where((entry) => entry.mealType == mealTypeId)
.toList();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _MealCard(
mealTypeOption: mealTypeOption,
entries: mealEntries,
dateString: dateString,
),
);
}),
],
);
}
}
Future<void> _pickAndShowDishResult(
BuildContext context,
WidgetRef ref,
String mealTypeId,
) async {
// 1. Choose image source
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Камера'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Галерея'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null || !context.mounted) return;
// 2. Pick image
final image = await ImagePicker().pickImage(
source: source,
imageQuality: 70,
maxWidth: 1024,
maxHeight: 1024,
);
if (image == null || !context.mounted) return;
// 3. Show loading
// Capture root navigator now (before await) to avoid using the wrong one later.
// showDialog defaults to useRootNavigator: true; Navigator.pop(context) would resolve
// to GoRouter's inner navigator instead, which only has /home and would crash.
final rootNavigator = Navigator.of(context, rootNavigator: true);
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Распознаём...'),
],
),
),
);
// 4. Call API
try {
final dish = await ref.read(recognitionServiceProvider).recognizeDish(image);
if (!context.mounted) return;
rootNavigator.pop(); // close loading
// 5. Show result as bottom sheet
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: dish,
preselectedMealType: mealTypeId,
onAdded: () => Navigator.pop(sheetContext),
),
);
} catch (recognitionError) {
debugPrint('Dish recognition error: $recognitionError');
if (context.mounted) {
rootNavigator.pop(); // close loading
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось распознать. Попробуйте ещё раз.'),
),
);
}
}
}
class _MealCard extends ConsumerWidget {
final MealTypeOption mealTypeOption;
final List<DiaryEntry> entries;
final String dateString;
const _MealCard({
required this.mealTypeOption,
required this.entries,
required this.dateString,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final totalCalories = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.calories ?? 0));
return Card(
child: Column(
children: [
// Header row
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
child: Row(
children: [
Text(mealTypeOption.emoji,
style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(mealTypeOption.label,
style: theme.textTheme.titleSmall),
const Spacer(),
if (totalCalories > 0)
Text(
'${totalCalories.toInt()} ккал',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: const Icon(Icons.add, size: 20),
visualDensity: VisualDensity.compact,
tooltip: 'Добавить блюдо',
onPressed: () => _pickAndShowDishResult(
context, ref, mealTypeOption.id),
),
],
),
),
// Diary entries
if (entries.isNotEmpty) ...[
const Divider(height: 1, indent: 16),
...entries.map((entry) => _DiaryEntryTile(
entry: entry,
onDelete: () => ref
.read(diaryProvider(dateString).notifier)
.remove(entry.id),
)),
],
],
),
);
}
}
class _DiaryEntryTile extends StatelessWidget {
final DiaryEntry entry;
final VoidCallback onDelete;
const _DiaryEntryTile({required this.entry, required this.onDelete});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final calories = entry.calories?.toInt();
final hasProtein = (entry.proteinG ?? 0) > 0;
final hasFat = (entry.fatG ?? 0) > 0;
final hasCarbs = (entry.carbsG ?? 0) > 0;
return ListTile(
dense: true,
title: Text(entry.name, style: theme.textTheme.bodyMedium),
subtitle: (hasProtein || hasFat || hasCarbs)
? Text(
[
if (hasProtein) 'Б ${entry.proteinG!.toStringAsFixed(1)}',
if (hasFat) 'Ж ${entry.fatG!.toStringAsFixed(1)}',
if (hasCarbs) 'У ${entry.carbsG!.toStringAsFixed(1)}',
].join(' '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (calories != null)
Text(
'$calories ккал',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: Icon(Icons.delete_outline,
size: 18, color: theme.colorScheme.error),
visualDensity: VisualDensity.compact,
onPressed: onDelete,
),
],
),
);
}
}
// ── Expiring banner ───────────────────────────────────────────
class _ExpiringBanner extends StatelessWidget {
final List<ExpiringSoon> items;
const _ExpiringBanner({required this.items});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.errorContainer;
final onColor = theme.colorScheme.onErrorContainer;
return GestureDetector(
onTap: () => context.push('/products'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: onColor, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Истекает срок годности',
style: theme.textTheme.labelMedium?.copyWith(
color: onColor, fontWeight: FontWeight.w600),
),
Text(
items
.take(3)
.map((expiringSoonItem) =>
'${expiringSoonItem.name}${expiringSoonItem.expiryLabel}')
.join(', '),
style:
theme.textTheme.bodySmall?.copyWith(color: onColor),
),
],
),
),
Icon(Icons.chevron_right, color: onColor),
],
),
),
);
}
}
// ── Quick actions ─────────────────────────────────────────────
class _QuickActionsRow extends StatelessWidget {
final String date;
const _QuickActionsRow({required this.date});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _ActionButton(
icon: Icons.document_scanner_outlined,
label: 'Сканировать',
onTap: () => context.push('/scan'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
icon: Icons.calendar_month_outlined,
label: 'Меню',
onTap: () => context.push('/menu'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
icon: Icons.book_outlined,
label: 'Дневник',
onTap: () => context.push('/menu/diary', extra: date),
),
),
],
);
}
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ActionButton(
{required this.icon, required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(icon, size: 24),
const SizedBox(height: 6),
Text(label,
style: theme.textTheme.labelSmall,
textAlign: TextAlign.center),
],
),
),
),
);
}
}
// ── Section title ─────────────────────────────────────────────
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) =>
Text(text, style: Theme.of(context).textTheme.titleSmall);
}
// ── Recommendations row ───────────────────────────────────────
class _RecommendationsRow extends StatelessWidget {
final List<HomeRecipe> recipes;
const _RecommendationsRow({required this.recipes});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 168,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) =>
_RecipeCard(recipe: recipes[index]),
),
);
}
}
class _RecipeCard extends StatelessWidget {
final HomeRecipe recipe;
const _RecipeCard({required this.recipe});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: 140,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
recipe.imageUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: recipe.imageUrl,
height: 96,
width: double.infinity,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => _imagePlaceholder(),
)
: _imagePlaceholder(),
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Text(
recipe.title,
style: theme.textTheme.bodySmall
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (recipe.calories != null)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text(
'${recipe.calories!.toInt()} ккал',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
),
],
),
),
),
);
}
Widget _imagePlaceholder() => Container(
height: 96,
color: Colors.grey.shade200,
child: const Icon(Icons.restaurant, color: Colors.grey),
);
}