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>
This commit is contained in:
dbastrikin
2026-03-17 12:34:30 +02:00
parent ddbc8e2bc0
commit 2a95bcd53c
2 changed files with 324 additions and 36 deletions

View File

@@ -1,8 +1,11 @@
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';
@@ -15,6 +18,9 @@ class HomeScreen extends ConsumerWidget {
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()),
@@ -34,7 +40,7 @@ class HomeScreen extends ConsumerWidget {
sliver: SliverList(
delegate: SliverChildListDelegate([
const SizedBox(height: 16),
_CaloriesCard(today: summary.today),
_CaloriesCard(today: summary.today, goalType: goalType),
const SizedBox(height: 16),
_TodayMealsCard(plan: summary.today.plan),
if (summary.expiringSoon.isNotEmpty) ...[
@@ -114,62 +120,103 @@ class _AppBar extends StatelessWidget {
class _CaloriesCard extends StatelessWidget {
final TodaySummary today;
const _CaloriesCard({required this.today});
final String? goalType;
const _CaloriesCard({required this.today, required this.goalType});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (today.dailyGoal == 0) return const SizedBox.shrink();
final theme = Theme.of(context);
final logged = today.loggedCalories.toInt();
final goal = today.dailyGoal;
final remaining = today.remainingCalories.toInt();
final progress = today.progress;
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(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(20),
child: Row(
children: [
Text('Калории сегодня', style: theme.textTheme.titleSmall),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
'$logged',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: ringColor,
),
),
Text(
'из $goal ккал',
'ккал',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
'цель: $goal',
style: theme.textTheme.labelSmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
Text(
'осталось $remaining',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
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,
),
],
),
),
],
@@ -179,6 +226,143 @@ class _CaloriesCard extends StatelessWidget {
}
}
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 {