diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index acde6fd..f22811b 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -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 { diff --git a/docs/calorie_ring_color_spec.md b/docs/calorie_ring_color_spec.md new file mode 100644 index 0000000..95bceb5 --- /dev/null +++ b/docs/calorie_ring_color_spec.md @@ -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` |