Files
food-ai/client/lib/features/home/home_screen.dart
dbastrikin 2a95bcd53c feat: replace linear calorie bar with goal-aware ring widget
The home screen CaloriesCard now uses a circular ring (CustomPainter)
instead of a LinearProgressIndicator. Ring colour is determined by the
user's goal type (lose / maintain / gain) with inverted semantics for
the gain goal — red means undereating, not overeating.

Overflow beyond 100% is shown as a thinner second-lap arc in the same
warning colour. Numbers (logged kcal, goal, remaining/overage) are
displayed both inside the ring and in a stat column to the right.

Adds docs/calorie_ring_color_spec.md with the full colour threshold
specification per goal type.

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

646 lines
20 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 '../../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<TodayMealPlan> 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<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((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<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: () {}, // 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),
);
}