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,64 +120,105 @@ 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 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();
final progress = today.progress;
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(
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: [
Text(
'$logged ккал',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
_CalorieStat(
label: 'Потреблено',
value: '$logged ккал',
valueColor: ringColor,
),
Text(
'из $goal ккал',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
const SizedBox(height: 12),
_CalorieStat(
label: isOverGoal ? 'Превышение' : 'Осталось',
value: secondaryLabel,
valueColor: secondaryColor,
),
const SizedBox(height: 12),
_CalorieStat(
label: 'Цель',
value: '$goal ккал',
valueColor: AppColors.textPrimary,
),
],
),
),
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,
),
),
],
),
),
@@ -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 {

View File

@@ -0,0 +1,104 @@
# Calorie Ring — Colour Logic Specification
## Overview
The home screen displays daily calorie progress as a circular ring. The ring colour conveys
how the user is tracking relative to their calorie goal. Because the meaning of "over goal"
differs by goal type, each goal type has its own colour thresholds.
All colours are from the iOS system palette already defined in `AppColors`.
---
## Goal types
| Value | Russian | Semantic |
|---|---|---|
| `lose` | Похудение | Daily calories are a **ceiling** — eating less is success, eating over is a warning |
| `maintain` | Поддержание | Daily calories are a **target** — closeness in either direction is success |
| `gain` | Набор массы | Daily calories are a **floor** — eating enough (or more) is success, eating too little is a warning |
---
## Colour thresholds
Progress is defined as `loggedCalories / dailyGoal` (raw, unclamped — can exceed 1.0).
### `lose` — calorie ceiling
| Progress range | Colour | Hex | Message to user |
|---|---|---|---|
| 0% 74% | Blue | `#007AFF` | Well within budget |
| 75% 94% | Green | `#34C759` | Approaching limit — on track |
| 95% 109% | Yellow / Orange | `#FF9500` | Slightly over limit |
| ≥ 110% | Red | `#FF3B30` | Significantly over limit |
**Rationale:** For weight loss, the calorie limit is the primary constraint. Green signals
the user is tracking well towards their limit without excess. Red signals a meaningful
overshoot that threatens the calorie deficit required for weight loss.
---
### `maintain` — calorie target (bidirectional)
| Progress range | Colour | Hex | Message to user |
|---|---|---|---|
| < 70% | Blue | `#007AFF` | Significantly under target |
| 70% 89% | Yellow / Orange | `#FF9500` | Slightly under target |
| 90% 110% | Green | `#34C759` | On target ✓ |
| 111% 124% | Yellow / Orange | `#FF9500` | Slightly over target |
| ≥ 125% | Red | `#FF3B30` | Significantly over target |
**Rationale:** Maintenance is a two-sided target. Green covers a ±10% acceptance band.
Yellow is a soft nudge in either direction. Red only appears at ≥ 125% over (meaningful
calorie surplus that would cause weight gain) or the symmetric < 70% (meaningful deficit
that would cause weight loss).
---
### `gain` — calorie floor (semantics inverted)
| Progress range | Colour | Hex | Message to user |
|---|---|---|---|
| < 60% | Red | `#FF3B30` | Far below minimum — eat more |
| 60% 84% | Yellow / Orange | `#FF9500` | Under target |
| 85% 115% | Green | `#34C759` | At or above target ✓ |
| > 115% | Blue | `#007AFF` | Well above target — informational |
**Rationale:** For muscle gain, not eating enough is the primary failure mode. Red means
"not enough calories for muscle synthesis". Green means the calorie surplus target is met.
Blue (rather than red) is used above 115% because eating extra while bulking is neutral to
positive — it does not warrant an alarming colour.
---
### Unknown / unset goal
When `user.goal` is `null` or an unrecognised value, the ring always shows Blue `#007AFF`
(the app's primary colour) with no semantic judgement applied.
---
## Overflow arc
When `loggedCalories > dailyGoal` (progress > 100%), the ring is visually "full" and a
second overlapping arc is drawn from the start point (top/12 o'clock) clockwise to
`(progress 1.0) × 360°`. This overflow arc is rendered:
- Same colour as the main arc (which has already shifted to warning/error colour)
- `strokeWidth` reduced to 6 px (vs. 10 px for the main arc) for visual distinction
- Opacity 70% to distinguish it from the primary stroke
The text inside the ring switches from "осталось X" to "+X перебор" when in overflow state.
---
## Implementation reference
| Symbol | Location |
|---|---|
| `_ringColorFor(double rawProgress, String? goalType)` | `client/lib/features/home/home_screen.dart` |
| `AppColors.primary` (`#007AFF`) | `client/lib/core/theme/app_colors.dart` |
| `AppColors.success` (`#34C759`) | `client/lib/core/theme/app_colors.dart` |
| `AppColors.warning` (`#FF9500`) | `client/lib/core/theme/app_colors.dart` |
| `AppColors.error` (`#FF3B30`) | `client/lib/core/theme/app_colors.dart` |