Replace static 7-day ListView with a scrollable strip (365 days back,
today at the right edge) and a header row with:
- chevron buttons to step one day at a time
- selected date label ("Вт, 18 марта")
- "Сегодня" shortcut that appears when a past date is selected
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1057 lines
34 KiB
Dart
1057 lines
34 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 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,
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
);
|
||
}
|