diff --git a/assets/icons/nutrition-plan.png b/assets/icons/nutrition-plan.png new file mode 100644 index 000000000..4ec70cd30 Binary files /dev/null and b/assets/icons/nutrition-plan.png differ diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f768dcd6a..9b548005f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -254,6 +254,10 @@ "@noNutritionalPlans": { "description": "Message shown when the user has no nutritional plans" }, + "noNutritionalPlansSubtitle": "Erstelle deinen ersten Plan, um deine Ernährung zu verfolgen", + "@noNutritionalPlansSubtitle": { + "description": "Subtitle shown when the user has no nutritional plans" + }, "todaysWorkout": "Dein Training heute", "@todaysWorkout": {}, "nrOfSets": "Sätze pro Übung: {nrOfSets}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 003c43ea6..c840b4ef6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -354,6 +354,10 @@ "@noNutritionalPlans": { "description": "Message shown when the user has no nutritional plans" }, + "noNutritionalPlansSubtitle": "Create your first plan to start tracking your nutrition", + "@noNutritionalPlansSubtitle": { + "description": "Subtitle shown when the user has no nutritional plans" + }, "onlyLogging": "Only track calories", "onlyLoggingHelpText": "Check the box if you only want to log your calories and don't want to setup a detailed nutritional plan with specific meals", "goalMacro": "Macro goals", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 395cd1e2f..2eeb47ea5 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -158,6 +158,10 @@ "@noNutritionalPlans": { "description": "Message shown when the user has no nutritional plans" }, + "noNutritionalPlansSubtitle": "Crea tu primer plan para comenzar a controlar tu nutrición", + "@noNutritionalPlansSubtitle": { + "description": "Subtitle shown when the user has no nutritional plans" + }, "nutritionalPlans": "Planes nutricionales", "@nutritionalPlans": {}, "nutritionalDiary": "Diario nutricional", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 902d4c582..93bff9155 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -119,6 +119,10 @@ "@noNutritionalPlans": { "description": "Message shown when the user has no nutritional plans" }, + "noNutritionalPlansSubtitle": "Créez votre premier plan pour commencer à suivre votre nutrition", + "@noNutritionalPlansSubtitle": { + "description": "Subtitle shown when the user has no nutritional plans" + }, "nutritionalPlans": "Programmes nutritionnels", "@nutritionalPlans": {}, "nutritionalDiary": "Journal nutritionnel", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 4cba50c47..2359a30b0 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -308,6 +308,10 @@ "@noNutritionalPlans": { "description": "Message shown when the user has no nutritional plans" }, + "noNutritionalPlansSubtitle": "Utwórz swój pierwszy plan, aby zacząć śledzić odżywianie", + "@noNutritionalPlansSubtitle": { + "description": "Subtitle shown when the user has no nutritional plans" + }, "plateCalculator": "Obciążnik", "@plateCalculator": { "description": "Label used for the plate calculator in the gym mode" diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 86bf31714..3cdd951c9 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -381,6 +381,10 @@ "@noNutritionalPlans": { "description": "Message shown when the user has no nutritional plans" }, + "noNutritionalPlansSubtitle": "Crie seu primeiro plano para começar a acompanhar sua nutrição", + "@noNutritionalPlansSubtitle": { + "description": "Subtitle shown when the user has no nutritional plans" + }, "goToDetailPage": "Ir para a página de detalhes", "@goToDetailPage": {}, "anErrorOccurred": "Um erro ocorreu!", diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 6a78e3bd7..53e0e9712 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -30,6 +30,7 @@ const Color wgerPrimaryColorLight = Color(0xff94B2DB); const Color wgerSecondaryColor = Color(0xffe63946); const Color wgerSecondaryColorLight = Color(0xffF6B4BA); const Color wgerTertiaryColor = Color(0xFF6CA450); +const Color wgerAccentColor = Color(0xFF3B82F6); const FlexSubThemesData wgerSubThemeData = FlexSubThemesData( fabSchemeColor: SchemeColor.secondary, diff --git a/lib/widgets/dashboard/widgets/nutrition.dart b/lib/widgets/dashboard/widgets/nutrition.dart index 08f875c83..3826b8e0a 100644 --- a/lib/widgets/dashboard/widgets/nutrition.dart +++ b/lib/widgets/dashboard/widgets/nutrition.dart @@ -17,8 +17,6 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter_svg_icons/flutter_svg_icons.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; @@ -26,8 +24,8 @@ import 'package:wger/providers/nutrition.dart'; import 'package:wger/screens/form_screen.dart'; import 'package:wger/screens/log_meals_screen.dart'; import 'package:wger/screens/nutritional_plan_screen.dart'; -import 'package:wger/widgets/dashboard/widgets/nothing_found.dart'; -import 'package:wger/widgets/nutrition/charts.dart'; +import 'package:wger/theme/theme.dart'; +import 'package:wger/widgets/nutrition/nutrition_card.dart'; import 'package:wger/widgets/nutrition/forms.dart'; class DashboardNutritionWidget extends StatefulWidget { @@ -50,100 +48,179 @@ class _DashboardNutritionWidgetState extends State { @override Widget build(BuildContext context) { - return Card( - child: Column( - children: [ - ListTile( - title: Text( - _hasContent ? _plan!.description : AppLocalizations.of(context).nutritionalPlan, - style: Theme.of(context).textTheme.headlineSmall, - ), - subtitle: Text( - _hasContent - ? DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(_plan!.creationDate) - : '', - ), - leading: Icon( - Icons.restaurant, - color: Theme.of(context).textTheme.headlineSmall!.color, - ), + return Padding( + padding: const EdgeInsets.all(4.0), + child: Material( + elevation: 0, + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.light + ? Colors.white + : Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 20, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ], ), - if (_hasContent) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 15), - child: FlNutritionalPlanGoalWidget(nutritionalPlan: _plan!), + child: Column( + children: [ + // Modern header with title and chevron + InkWell( + onTap: _hasContent + ? () { + Navigator.of(context).pushNamed( + NutritionalPlanScreen.routeName, + arguments: _plan, + ); + } + : null, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context).nutritionalPlan, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (_hasContent) + Icon( + Icons.chevron_right, + color: Colors.grey.shade400, + size: 28, + ), + ], + ), + ), ), - ) - else - NothingFound( - AppLocalizations.of(context).noNutritionalPlans, - AppLocalizations.of(context).newNutritionalPlan, - PlanForm(), - ), - if (_hasContent) - LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text(AppLocalizations.of(context).goToDetailPage), - onPressed: () { - Navigator.of(context).pushNamed( - NutritionalPlanScreen.routeName, - arguments: _plan, - ); - }, + + // Progress indicators or empty state + if (_hasContent) + NutritionGoalsWidget( + nutritionalPlan: _plan!, + onLogIngredient: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).logIngredient, + getIngredientLogForm(_plan!), + hasListView: true, + ), + ); + }, + onLogMeal: () { + Navigator.of(context).pushNamed( + LogMealsScreen.routeName, + arguments: _plan, + ); + }, + ) + else + // Empty state - inviting the user to create their first nutritional plan + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + children: [ + const SizedBox(height: 16), + // 3D nutrition plan icon + Image.asset( + 'assets/icons/nutrition-plan.png', + width: 120, + height: 120, + ), + const SizedBox(height: 20), + // Inviting title + Text( + AppLocalizations.of(context).noNutritionalPlans, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey.shade300 + : Colors.grey.shade800, ), - Row( - children: [ - IconButton( - icon: const SvgIcon( - icon: SvgIconData('assets/icons/ingredient-diary.svg'), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + // Subtitle + Text( + AppLocalizations.of(context).noNutritionalPlansSubtitle, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey.shade400 + : Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + // Modern button to create nutritional plan + Material( + color: wgerAccentColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).newNutritionalPlan, + hasListView: true, + PlanForm(), ), - tooltip: AppLocalizations.of(context).logIngredient, - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).logIngredient, - getIngredientLogForm(_plan!), - hasListView: true, + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.add_rounded, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context).newNutritionalPlan, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, ), - ); - }, + ), + ], ), - IconButton( - icon: const SvgIcon( - icon: SvgIconData('assets/icons/meal-diary.svg'), - ), - tooltip: AppLocalizations.of(context).logMeal, - onPressed: () { - Navigator.of( - context, - ).pushNamed(LogMealsScreen.routeName, arguments: _plan); - }, - ), - ], + ), ), - ], - ), + ), + const SizedBox(height: 8), + ], ), - ); - }, - ), - ], + ), + ], + ), + ), ), ); } + } diff --git a/lib/widgets/nutrition/nutrition_card.dart b/lib/widgets/nutrition/nutrition_card.dart new file mode 100644 index 000000000..92dea3a1a --- /dev/null +++ b/lib/widgets/nutrition/nutrition_card.dart @@ -0,0 +1,400 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (C) 2020, 2021 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * wger Workout Manager is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg_icons/flutter_svg_icons.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/nutrition/nutritional_plan.dart'; +import 'package:wger/theme/theme.dart'; + +/// Semicircular progress indicator for nutrition goals +class NutritionGoalsWidget extends StatelessWidget { + const NutritionGoalsWidget({ + super.key, + required NutritionalPlan nutritionalPlan, + this.onLogIngredient, + this.onLogMeal, + }) : _nutritionalPlan = nutritionalPlan; + + final NutritionalPlan _nutritionalPlan; + final VoidCallback? onLogIngredient; + final VoidCallback? onLogMeal; + + @override + Widget build(BuildContext context) { + final plan = _nutritionalPlan; + final goals = plan.nutritionalGoals; + final today = plan.loggedNutritionalValuesToday; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 16), + child: Column( + children: [ + // Large calorie semicircle with action buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (onLogIngredient != null) + Expanded( + child: Center( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.light + ? Colors.white + : Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.06) + : Colors.white.withOpacity(0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.08) + : Colors.black.withOpacity(0.3), + blurRadius: 8, + spreadRadius: 0, + offset: const Offset(0, 0), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onLogIngredient, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(12), + child: const SvgIcon( + icon: SvgIconData('assets/icons/ingredient-diary.svg'), + ), + ), + ), + ), + ), + ), + ), + Expanded( + flex: 2, + child: _buildLargeSemicircle( + context, + label: AppLocalizations.of(context).energy, + value: today.energy, + goal: goals.energy, + unit: AppLocalizations.of(context).kcal, + color: wgerAccentColor, + ), + ), + if (onLogMeal != null) + Expanded( + child: Center( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.light + ? Colors.white + : Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.06) + : Colors.white.withOpacity(0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.08) + : Colors.black.withOpacity(0.3), + blurRadius: 8, + spreadRadius: 0, + offset: const Offset(0, 0), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onLogMeal, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(12), + child: const SvgIcon( + icon: SvgIconData('assets/icons/meal-diary.svg'), + ), + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 20), + // Three macro semicircles in a row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: _buildSmallSemicircle( + context, + label: AppLocalizations.of(context).protein, + value: today.protein, + goal: goals.protein, + unit: AppLocalizations.of(context).g, + color: wgerAccentColor, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildSmallSemicircle( + context, + label: AppLocalizations.of(context).carbohydrates, + value: today.carbohydrates, + goal: goals.carbohydrates, + unit: AppLocalizations.of(context).g, + color: wgerAccentColor, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildSmallSemicircle( + context, + label: AppLocalizations.of(context).fat, + value: today.fat, + goal: goals.fat, + unit: AppLocalizations.of(context).g, + color: wgerAccentColor, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildLargeSemicircle( + BuildContext context, { + required String label, + required double value, + required double? goal, + required String unit, + required Color color, + }) { + final hasGoal = goal != null && goal > 0; + final progress = hasGoal ? (value / goal).clamp(0.0, 1.0) : 0.0; + final isOverGoal = hasGoal && value > goal; + final displayColor = isOverGoal ? const Color(0xFFEF4444) : color; + + return Column( + children: [ + SizedBox( + height: 80, + child: CustomPaint( + painter: _SemicirclePainter( + progress: progress, + color: hasGoal ? displayColor : const Color(0xFFE5E7EB), + backgroundColor: const Color(0xFFF3F4F6), + strokeWidth: 10, + isOverGoal: isOverGoal, + ), + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value.toStringAsFixed(0), + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: hasGoal ? displayColor : Colors.grey.shade400, + ), + ), + if (hasGoal) + Text( + '/ ${goal.toStringAsFixed(0)} $unit', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + ), + ) + else + Text( + unit, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey.shade400, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: hasGoal ? Colors.grey.shade800 : Colors.grey.shade400, + ), + ), + ], + ); + } + + Widget _buildSmallSemicircle( + BuildContext context, { + required String label, + required double value, + required double? goal, + required String unit, + required Color color, + }) { + final hasGoal = goal != null && goal > 0; + final progress = hasGoal ? (value / goal).clamp(0.0, 1.0) : 0.0; + final isOverGoal = hasGoal && value > goal; + final displayColor = isOverGoal ? const Color(0xFFEF4444) : color; + + return Column( + children: [ + SizedBox( + height: 50, + child: CustomPaint( + painter: _SemicirclePainter( + progress: progress, + color: hasGoal ? displayColor : const Color(0xFFE5E7EB), + backgroundColor: const Color(0xFFF3F4F6), + strokeWidth: 6, + isOverGoal: isOverGoal, + ), + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value.toStringAsFixed(0), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: hasGoal ? displayColor : Colors.grey.shade400, + ), + ), + if (hasGoal) + Text( + '/ ${goal.toStringAsFixed(0)} $unit', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + fontSize: 9, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 3), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: hasGoal ? Colors.grey.shade700 : Colors.grey.shade400, + fontSize: 11, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} + +/// Custom painter for semicircular progress indicator +class _SemicirclePainter extends CustomPainter { + final double progress; + final Color color; + final Color backgroundColor; + final double strokeWidth; + final bool isOverGoal; + + _SemicirclePainter({ + required this.progress, + required this.color, + required this.backgroundColor, + required this.strokeWidth, + this.isOverGoal = false, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height); + final radius = min(size.width / 2, size.height) - strokeWidth / 2; + + // Draw background arc + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + pi, // Start at left (180 degrees) + pi, // Sweep 180 degrees + false, + backgroundPaint, + ); + + // Draw progress arc + if (progress > 0) { + final progressPaint = Paint() + ..color = isOverGoal + ? const Color(0xFFEF4444) + : color // Solid red if over goal + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + pi, // Start at left (180 degrees) + pi * progress, // Sweep based on progress + false, + progressPaint, + ); + } + } + + @override + bool shouldRepaint(_SemicirclePainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.color != color || + oldDelegate.isOverGoal != isOverGoal; + } +}