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/home_summary.dart'; import '../profile/profile_provider.dart'; import 'home_provider.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(homeProvider); final userName = ref.watch(profileProvider).valueOrNull?.name; final goalType = ref.watch( profileProvider.select((asyncUser) => asyncUser.valueOrNull?.goal)); return Scaffold( body: state.when( loading: () => const Center(child: CircularProgressIndicator()), error: (_, __) => Center( child: FilledButton( onPressed: () => ref.read(homeProvider.notifier).load(), child: const Text('Повторить'), ), ), data: (summary) => RefreshIndicator( onRefresh: () => ref.read(homeProvider.notifier).load(), child: CustomScrollView( slivers: [ _AppBar(summary: summary, userName: userName), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), sliver: SliverList( delegate: SliverChildListDelegate([ const SizedBox(height: 16), _CaloriesCard(today: summary.today, goalType: goalType), const SizedBox(height: 16), _TodayMealsCard(plan: summary.today.plan), if (summary.expiringSoon.isNotEmpty) ...[ const SizedBox(height: 16), _ExpiringBanner(items: summary.expiringSoon), ], const SizedBox(height: 16), _QuickActionsRow(date: summary.today.date), if (summary.recommendations.isNotEmpty) ...[ const SizedBox(height: 20), _SectionTitle('Рекомендуем приготовить'), const SizedBox(height: 12), _RecommendationsRow(recipes: summary.recommendations), ], ]), ), ), ], ), ), ), ); } } // ── App bar ─────────────────────────────────────────────────── class _AppBar extends StatelessWidget { final HomeSummary summary; final String? userName; const _AppBar({required this.summary, 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; } String get _dateLabel { final now = DateTime.now(); const months = [ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', ]; return '${now.day} ${months[now.month - 1]}'; } @override Widget build(BuildContext context) { final theme = Theme.of(context); return SliverAppBar( pinned: false, floating: true, title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_greeting, style: theme.textTheme.titleMedium), Text( _dateLabel, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ], ), ); } } // ── Calories card ───────────────────────────────────────────── class _CaloriesCard extends StatelessWidget { final TodaySummary today; final String? goalType; const _CaloriesCard({required this.today, required this.goalType}); @override Widget build(BuildContext context) { if (today.dailyGoal == 0) return const SizedBox.shrink(); final theme = Theme.of(context); final logged = today.loggedCalories.toInt(); final goal = today.dailyGoal; final rawProgress = goal > 0 ? today.loggedCalories / goal : 0.0; final isOverGoal = rawProgress > 1.0; final ringColor = _ringColorFor(rawProgress, goalType); final String secondaryLabel; final Color secondaryColor; if (isOverGoal) { final overBy = (today.loggedCalories - goal).toInt(); secondaryLabel = '+$overBy перебор'; secondaryColor = AppColors.error; } else { final remaining = today.remainingCalories.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( 'цель: $goal', 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: '$goal ккал', 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, ), ), ], ); } } // ── Ring colour logic ────────────────────────────────────────── // Returns the ring stroke colour based on rawProgress and goal type. // See docs/calorie_ring_color_spec.md for full specification. Color _ringColorFor(double rawProgress, String? goalType) { switch (goalType) { case 'lose': // Ceiling semantics: over goal is bad 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': // Bidirectional target: closeness in either direction is good 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': // Floor semantics: under goal is bad, over is neutral 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; // 10 px inset on each side const strokeWidth = 10.0; const overflowStrokeWidth = 6.0; // Arc starts at 12 o'clock (−π/2) and goes clockwise const startAngle = -math.pi / 2; // Background track — full circle final trackPaint = Paint() ..color = AppColors.separator.withValues(alpha: 0.5) ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round; canvas.drawCircle(center, radius, trackPaint); // Primary arc — clamp sweep to max one full circle 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, ); } // Overflow arc — second lap when rawProgress > 1.0 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; } // ── Today meals card ────────────────────────────────────────── class _TodayMealsCard extends StatelessWidget { final List plan; const _TodayMealsCard({required this.plan}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(bottom: 8), child: Text('Приёмы пищи сегодня', style: theme.textTheme.titleSmall), ), Card( child: Column( children: plan.asMap().entries.map((entry) { final i = entry.key; final meal = entry.value; return Column( children: [ _MealRow(meal: meal), if (i < plan.length - 1) const Divider(height: 1, indent: 16), ], ); }).toList(), ), ), ], ); } } class _MealRow extends StatelessWidget { final TodayMealPlan meal; const _MealRow({required this.meal}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return ListTile( leading: Text(meal.mealEmoji, style: const TextStyle(fontSize: 24)), title: Text(meal.mealLabel, style: theme.textTheme.labelMedium), subtitle: meal.hasRecipe ? Text(meal.recipeTitle!, style: theme.textTheme.bodyMedium) : Text( 'Не запланировано', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), ), trailing: meal.calories != null ? Text( '≈${meal.calories!.toInt()} ккал', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant), ) : const Icon(Icons.chevron_right), onTap: () => context.push('/menu'), ); } } // ── Expiring banner ─────────────────────────────────────────── class _ExpiringBanner extends StatelessWidget { final List 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((e) => '${e.name} — ${e.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 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: () {}, // recipes detail can be added later 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), ); }