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:
@@ -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 {
|
||||
|
||||
104
docs/calorie_ring_color_spec.md
Normal file
104
docs/calorie_ring_color_spec.md
Normal 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` |
|
||||
Reference in New Issue
Block a user