Backend: - Translate all recognition prompts (receipt, products, dish) from Russian to English - Add lang parameter to Recognizer interface and pass locale.FromContext in handlers - DishResult type uses candidates array for multi-candidate responses Client: - Add meal tracking: diary provider, date selector, meal type model - DishResult parser: backward-compatible with legacy flat format and new candidates format - DishResultScreen: sticky bottom button, full-width portion/meal-type inputs, КБЖУ disclaimer moved under nutrition card, add date field to diary POST body - Recognition prompts now return dish/product names in user's preferred language - Onboarding, profile, home screen visual updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
909 lines
28 KiB
Dart
909 lines
28 KiB
Dart
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 '../../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 '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 StatelessWidget {
|
||
final DateTime selectedDate;
|
||
final ValueChanged<DateTime> onDateSelected;
|
||
|
||
const _DateSelector({
|
||
required this.selectedDate,
|
||
required this.onDateSelected,
|
||
});
|
||
|
||
static const _weekDayShort = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||
|
||
@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(
|
||
selectedDate.year, selectedDate.month, selectedDate.day);
|
||
|
||
return SizedBox(
|
||
height: 64,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: 7,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||
itemBuilder: (context, index) {
|
||
final date = todayNormalized.subtract(Duration(days: 6 - index));
|
||
final isSelected = date == selectedNormalized;
|
||
final isToday = date == todayNormalized;
|
||
|
||
return GestureDetector(
|
||
onTap: () => onDateSelected(date),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
width: 44,
|
||
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 || isToday ? FontWeight.w700 : FontWeight.normal,
|
||
color: isSelected
|
||
? theme.colorScheme.onPrimary
|
||
: isToday
|
||
? 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,
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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: () =>
|
||
context.push('/scan', extra: 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),
|
||
);
|
||
}
|