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>
646 lines
20 KiB
Dart
646 lines
20 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/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),
|
||
);
|
||
}
|