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 {
|
||||
|
||||
Reference in New Issue
Block a user