diff --git a/assets/icons/measuring-tape.png b/assets/icons/measuring-tape.png new file mode 100644 index 000000000..24b0c8158 Binary files /dev/null and b/assets/icons/measuring-tape.png differ diff --git a/integration_test/6_weight.dart b/integration_test/6_weight.dart index 849ac01e9..2d533c7d8 100644 --- a/integration_test/6_weight.dart +++ b/integration_test/6_weight.dart @@ -3,16 +3,14 @@ import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/body_weight.dart'; -import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/user.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/screens/weight_screen.dart'; import 'package:wger/theme/theme.dart'; +import 'package:wger/widgets/dashboard/widgets/weight.dart'; import '../test/utils.dart'; -import '../test/weight/weight_screen_test.mocks.dart'; +import '../test/weight/weight_entries_modal_test.mocks.dart'; +import '../test/weight/weight_provider_test.mocks.dart'; import '../test_data/body_weight.dart'; -import '../test_data/nutritional_plans.dart'; import '../test_data/profile.dart'; Widget createWeightScreen({Locale? locale}) { @@ -23,10 +21,6 @@ Widget createWeightScreen({Locale? locale}) { final mockUserProvider = MockUserProvider(); when(mockUserProvider.profile).thenReturn(tProfile1); - final mockNutritionPlansProvider = MockNutritionPlansProvider(); - when(mockNutritionPlansProvider.currentPlan).thenReturn(null); - when(mockNutritionPlansProvider.items).thenReturn([getNutritionalPlan()]); - return MediaQuery( data: MediaQueryData.fromView(WidgetsBinding.instance.platformDispatcher.views.first).copyWith( padding: EdgeInsets.zero, @@ -41,9 +35,6 @@ Widget createWeightScreen({Locale? locale}) { ChangeNotifierProvider( create: (context) => weightProvider, ), - ChangeNotifierProvider( - create: (context) => mockNutritionPlansProvider, - ), ], child: MaterialApp( locale: locale, @@ -51,8 +42,11 @@ Widget createWeightScreen({Locale? locale}) { localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: wgerLightTheme, - home: const WeightScreen(), - routes: {FormScreen.routeName: (ctx) => const FormScreen()}, + home: const Scaffold( + body: SingleChildScrollView( + child: DashboardWeightWidget(), + ), + ), ), ), ); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5c98ddd05..71efb325b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1187,5 +1187,19 @@ } }, "superset": "Superset", - "@superset": {} + "@superset": {}, + "entries": "Einträge", + "@entries": {}, + "week": "Woche", + "@week": {}, + "month": "Monat", + "@month": {}, + "sixMonths": "6 Monate", + "@sixMonths": {}, + "year": "Jahr", + "@year": {}, + "recentEntries": "Letzte Einträge", + "@recentEntries": {}, + "seeAll": "Alle anzeigen", + "@seeAll": {} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 003c43ea6..8647a6013 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -431,6 +431,10 @@ "@measurementCategoriesHelpText": {}, "measurementEntriesHelpText": "The unit used to measure the category such as 'cm' or '%'", "@measurementEntriesHelpText": {}, + "entries": "entries", + "@entries": { + "description": "Plural form of entry, used to show count of measurement entries" + }, "date": "Date", "@date": { "description": "The date of a workout log or body weight entry" @@ -1114,5 +1118,29 @@ "themeMode": "Theme mode", "darkMode": "Always dark mode", "lightMode": "Always light mode", - "systemMode": "System settings" + "systemMode": "System settings", + "week": "Week", + "@week": { + "description": "Time range option for one week" + }, + "month": "Month", + "@month": { + "description": "Time range option for one month" + }, + "sixMonths": "6M", + "@sixMonths": { + "description": "Time range option for six months" + }, + "year": "Year", + "@year": { + "description": "Time range option for one year" + }, + "all": "All", + "@all": { + "description": "Time range option for all-time data" + }, + "recentEntries": "Recent entries", + "@recentEntries": {}, + "seeAll": "See all", + "@seeAll": {} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 395cd1e2f..5a2137b8f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1114,5 +1114,19 @@ "type": "String" } } - } + }, + "entries": "entradas", + "@entries": {}, + "week": "Semana", + "@week": {}, + "month": "Mes", + "@month": {}, + "sixMonths": "6 Meses", + "@sixMonths": {}, + "year": "Año", + "@year": {}, + "recentEntries": "Entradas recientes", + "@recentEntries": {}, + "seeAll": "Ver todos", + "@seeAll": {} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 429c9e541..c273d5521 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1184,5 +1184,19 @@ } }, "superset": "Superset", - "@superset": {} + "@superset": {}, + "entries": "entrées", + "@entries": {}, + "week": "Semaine", + "@week": {}, + "month": "Mois", + "@month": {}, + "sixMonths": "6 Mois", + "@sixMonths": {}, + "year": "Année", + "@year": {}, + "recentEntries": "Entrées récentes", + "@recentEntries": {}, + "seeAll": "Voir tout", + "@seeAll": {} } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 4cba50c47..6159fff41 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1012,5 +1012,19 @@ "systemMode": "Ustawienia systemu", "@systemMode": {}, "fitInWeek": "Dopasuj w tygodniu", - "@fitInWeek": {} + "@fitInWeek": {}, + "entries": "wpisy", + "@entries": {}, + "week": "Tydzień", + "@week": {}, + "month": "Miesiąc", + "@month": {}, + "sixMonths": "6 miesięcy", + "@sixMonths": {}, + "year": "Rok", + "@year": {}, + "recentEntries": "Ostatnie wpisy", + "@recentEntries": {}, + "seeAll": "Zobacz wszystkie", + "@seeAll": {} } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 86bf31714..4e40ca1d5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1032,5 +1032,19 @@ "identicalExercisePleaseDiscard": "Se encontrares um exercício igual ao que estás a introduzir, por favor descarta o teu rascunho e edita antes esse exercício.", "@identicalExercisePleaseDiscard": {}, "overview": "Panorama", - "@overview": {} + "@overview": {}, + "entries": "entradas", + "@entries": {}, + "week": "Semana", + "@week": {}, + "month": "Mês", + "@month": {}, + "sixMonths": "6 Meses", + "@sixMonths": {}, + "year": "Ano", + "@year": {}, + "recentEntries": "Entradas recentes", + "@recentEntries": {}, + "seeAll": "Ver todos", + "@seeAll": {} } diff --git a/lib/main.dart b/lib/main.dart index 477618816..79e978c3f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -48,7 +48,6 @@ import 'package:wger/screens/home_tabs_screen.dart'; import 'package:wger/screens/log_meal_screen.dart'; import 'package:wger/screens/log_meals_screen.dart'; import 'package:wger/screens/measurement_categories_screen.dart'; -import 'package:wger/screens/measurement_entries_screen.dart'; import 'package:wger/screens/nutritional_diary_screen.dart'; import 'package:wger/screens/nutritional_plan_screen.dart'; import 'package:wger/screens/nutritional_plans_screen.dart'; @@ -58,7 +57,6 @@ import 'package:wger/screens/routine_logs_screen.dart'; import 'package:wger/screens/routine_screen.dart'; import 'package:wger/screens/splash_screen.dart'; import 'package:wger/screens/update_app_screen.dart'; -import 'package:wger/screens/weight_screen.dart'; import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/core/about.dart'; import 'package:wger/widgets/core/log_overview.dart'; @@ -234,13 +232,11 @@ class MainApp extends StatelessWidget { GymModeScreen.routeName: (ctx) => const GymModeScreen(), HomeTabsScreen.routeName: (ctx) => HomeTabsScreen(), MeasurementCategoriesScreen.routeName: (ctx) => const MeasurementCategoriesScreen(), - MeasurementEntriesScreen.routeName: (ctx) => const MeasurementEntriesScreen(), NutritionalPlansScreen.routeName: (ctx) => const NutritionalPlansScreen(), NutritionalDiaryScreen.routeName: (ctx) => const NutritionalDiaryScreen(), NutritionalPlanScreen.routeName: (ctx) => const NutritionalPlanScreen(), LogMealsScreen.routeName: (ctx) => const LogMealsScreen(), LogMealScreen.routeName: (ctx) => const LogMealScreen(), - WeightScreen.routeName: (ctx) => const WeightScreen(), RoutineScreen.routeName: (ctx) => const RoutineScreen(), RoutineEditScreen.routeName: (ctx) => const RoutineEditScreen(), WorkoutLogsScreen.routeName: (ctx) => const WorkoutLogsScreen(), diff --git a/lib/screens/home_tabs_screen.dart b/lib/screens/home_tabs_screen.dart index ebb703985..d4c807550 100644 --- a/lib/screens/home_tabs_screen.dart +++ b/lib/screens/home_tabs_screen.dart @@ -17,7 +17,6 @@ */ import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:rive/rive.dart'; @@ -35,7 +34,6 @@ import 'package:wger/screens/dashboard.dart'; import 'package:wger/screens/gallery_screen.dart'; import 'package:wger/screens/nutritional_plans_screen.dart'; import 'package:wger/screens/routine_list_screen.dart'; -import 'package:wger/screens/weight_screen.dart'; class HomeTabsScreen extends StatefulWidget { final _logger = Logger('HomeTabsScreen'); @@ -79,7 +77,6 @@ class _HomeTabsScreenState extends State with SingleTickerProvid const DashboardScreen(), const RoutineListScreen(), const NutritionalPlansScreen(), - const WeightScreen(), const GalleryScreen(), ]; @@ -164,10 +161,6 @@ class _HomeTabsScreenState extends State with SingleTickerProvid icon: const Icon(Icons.restaurant), label: AppLocalizations.of(context).labelBottomNavNutrition, ), - NavigationDestination( - icon: const FaIcon(FontAwesomeIcons.weightScale, size: 20), - label: AppLocalizations.of(context).weight, - ), NavigationDestination( icon: const Icon(Icons.photo_library), label: AppLocalizations.of(context).gallery, diff --git a/lib/screens/measurement_categories_screen.dart b/lib/screens/measurement_categories_screen.dart index 6feffb47a..cfe7d1da0 100644 --- a/lib/screens/measurement_categories_screen.dart +++ b/lib/screens/measurement_categories_screen.dart @@ -21,35 +21,49 @@ import 'package:provider/provider.dart'; import 'package:wger/core/wide_screen_wrapper.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/measurement.dart'; -import 'package:wger/screens/form_screen.dart'; +import 'package:wger/widgets/core/time_range_tab_bar.dart'; import 'package:wger/widgets/measurements/categories.dart'; -import 'package:wger/widgets/measurements/forms.dart'; +import 'package:wger/widgets/measurements/charts.dart'; +import 'package:wger/widgets/measurements/edit_modals.dart'; -class MeasurementCategoriesScreen extends StatelessWidget { +class MeasurementCategoriesScreen extends StatefulWidget { const MeasurementCategoriesScreen(); static const routeName = '/measurement-categories'; + @override + State createState() => _MeasurementCategoriesScreenState(); +} + +class _MeasurementCategoriesScreenState extends State { + ChartTimeRange _selectedRange = ChartTimeRange.month; + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context).measurements)), floatingActionButton: FloatingActionButton( child: const Icon(Icons.add, color: Colors.white), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), - ), - ); - }, + onPressed: () => showEditCategoryModal(context, null), ), body: WidescreenWrapper( child: Consumer( - builder: (context, provider, child) => const CategoriesList(), + builder: (context, provider, child) => Column( + children: [ + // Time range tabs + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TimeRangeTabBar( + selectedRange: _selectedRange, + onRangeChanged: (range) => setState(() => _selectedRange = range), + ), + ), + // Categories list + Expanded( + child: CategoriesList(timeRange: _selectedRange), + ), + ], + ), ), ), ); diff --git a/lib/screens/measurement_entries_screen.dart b/lib/screens/measurement_entries_screen.dart deleted file mode 100644 index 3f30ed3e3..000000000 --- a/lib/screens/measurement_entries_screen.dart +++ /dev/null @@ -1,162 +0,0 @@ -/* - * This file is part of wger Workout Manager . - * Copyright (c) 2025 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. - * - * This program 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 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/core/exceptions/no_such_entry_exception.dart'; -import 'package:wger/core/wide_screen_wrapper.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/measurements/measurement_category.dart'; -import 'package:wger/providers/measurement.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/widgets/measurements/entries.dart'; -import 'package:wger/widgets/measurements/forms.dart'; - -enum MeasurementOptions { - edit, - delete, -} - -class MeasurementEntriesScreen extends StatelessWidget { - const MeasurementEntriesScreen(); - - static const routeName = '/measurement-entries'; - - @override - Widget build(BuildContext context) { - final categoryId = ModalRoute.of(context)!.settings.arguments as int; - final provider = Provider.of(context); - MeasurementCategory? category; - - try { - category = provider.findCategoryById(categoryId); - } on NoSuchEntryException { - Future.microtask(() { - if (context.mounted && Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }); - return const SizedBox(); // Return empty widget until pop happens - } - - return Scaffold( - appBar: AppBar( - title: Text(category.name), - actions: [ - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - switch (value) { - case MeasurementOptions.edit: - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).edit, - MeasurementCategoryForm(category), - ), - ); - break; - - case MeasurementOptions.delete: - showDialog( - context: context, - builder: (BuildContext contextDialog) { - return AlertDialog( - content: Text( - AppLocalizations.of(context).confirmDelete(category!.name), - ), - actions: [ - TextButton( - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - onPressed: () => Navigator.of(contextDialog).pop(), - ), - TextButton( - child: Text( - AppLocalizations.of(context).delete, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - onPressed: () { - // Confirmed, delete the workout - Provider.of( - context, - listen: false, - ).deleteCategory(category!.id!); - // Close the popup - Navigator.of(contextDialog).pop(); - - Navigator.of(context).pop(); // Exit detail screen - - // and inform the user - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).successfullyDeleted, - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - ], - ); - }, - ); - break; - } - }, - itemBuilder: (context) { - return [ - PopupMenuItem( - value: MeasurementOptions.edit, - child: Text(AppLocalizations.of(context).edit), - ), - PopupMenuItem( - value: MeasurementOptions.delete, - child: Text(AppLocalizations.of(context).delete), - ), - ]; - }, - ), - ], - ), - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add, color: Colors.white), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - MeasurementEntryForm(categoryId), - ), - ); - }, - ), - body: WidescreenWrapper( - child: SingleChildScrollView( - child: Consumer( - builder: (context, provider, child) => EntriesList(category!), - ), - ), - ), - ); - } -} diff --git a/lib/screens/weight_screen.dart b/lib/screens/weight_screen.dart deleted file mode 100644 index 5f41f99a7..000000000 --- a/lib/screens/weight_screen.dart +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/core/wide_screen_wrapper.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/providers/body_weight.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/widgets/core/app_bar.dart'; -import 'package:wger/widgets/weight/forms.dart'; -import 'package:wger/widgets/weight/weight_overview.dart'; - -class WeightScreen extends StatelessWidget { - const WeightScreen(); - - static const routeName = '/weight'; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: EmptyAppBar(AppLocalizations.of(context).weight), - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add, color: Colors.white), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - WeightForm(), - ), - ); - }, - ), - body: WidescreenWrapper( - child: SingleChildScrollView( - child: Consumer( - builder: (context, provider, child) => WeightOverview(provider), - ), - ), - ), - ); - } -} diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 6a78e3bd7..2a86f4a6f 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -30,6 +30,11 @@ 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); + +// Chart colors +const Color wgerChartGridColor = Color(0x1A000000); // Black with 10% opacity +const Color wgerChartGridColorDark = Color(0x1AFFFFFF); // White with 10% opacity const FlexSubThemesData wgerSubThemeData = FlexSubThemesData( fabSchemeColor: SchemeColor.secondary, diff --git a/lib/widgets/core/time_range_tab_bar.dart b/lib/widgets/core/time_range_tab_bar.dart new file mode 100644 index 000000000..b86b36c00 --- /dev/null +++ b/lib/widgets/core/time_range_tab_bar.dart @@ -0,0 +1,131 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/widgets/measurements/charts.dart'; + +/// A reusable time range tab bar for selecting chart time ranges. +/// +/// Displays tabs for Week, Month, 6 Months, and Year with consistent styling. +class TimeRangeTabBar extends StatelessWidget { + final ChartTimeRange selectedRange; + final ValueChanged onRangeChanged; + + const TimeRangeTabBar({ + super.key, + required this.selectedRange, + required this.onRangeChanged, + }); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Row( + children: [ + _TimeRangeTab( + range: ChartTimeRange.week, + label: AppLocalizations.of(context).week, + isSelected: selectedRange == ChartTimeRange.week, + isDarkMode: isDarkMode, + onTap: () => onRangeChanged(ChartTimeRange.week), + ), + const SizedBox(width: 8), + _TimeRangeTab( + range: ChartTimeRange.month, + label: AppLocalizations.of(context).month, + isSelected: selectedRange == ChartTimeRange.month, + isDarkMode: isDarkMode, + onTap: () => onRangeChanged(ChartTimeRange.month), + ), + const SizedBox(width: 8), + _TimeRangeTab( + range: ChartTimeRange.sixMonths, + label: AppLocalizations.of(context).sixMonths, + isSelected: selectedRange == ChartTimeRange.sixMonths, + isDarkMode: isDarkMode, + onTap: () => onRangeChanged(ChartTimeRange.sixMonths), + ), + const SizedBox(width: 8), + _TimeRangeTab( + range: ChartTimeRange.year, + label: AppLocalizations.of(context).year, + isSelected: selectedRange == ChartTimeRange.year, + isDarkMode: isDarkMode, + onTap: () => onRangeChanged(ChartTimeRange.year), + ), + const SizedBox(width: 8), + _TimeRangeTab( + range: ChartTimeRange.all, + label: AppLocalizations.of(context).all, + isSelected: selectedRange == ChartTimeRange.all, + isDarkMode: isDarkMode, + onTap: () => onRangeChanged(ChartTimeRange.all), + ), + ], + ); + } +} + +class _TimeRangeTab extends StatelessWidget { + final ChartTimeRange range; + final String label; + final bool isSelected; + final bool isDarkMode; + final VoidCallback onTap; + + const _TimeRangeTab({ + required this.range, + required this.label, + required this.isSelected, + required this.isDarkMode, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? (isDarkMode + ? Colors.white.withValues(alpha: 0.15) + : Colors.black.withValues(alpha: 0.08)) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected + ? (isDarkMode ? Colors.white : Colors.black87) + : (isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dashboard/calendar.dart b/lib/widgets/dashboard/calendar.dart index e7175fd15..ab58213b4 100644 --- a/lib/widgets/dashboard/calendar.dart +++ b/lib/widgets/dashboard/calendar.dart @@ -230,72 +230,75 @@ class _DashboardCalendarWidgetState extends State @override Widget build(BuildContext context) { - return Card( - child: Column( - children: [ - ListTile( - title: Text( - AppLocalizations.of(context).calendar, - style: Theme.of(context).textTheme.headlineMedium, + return Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Column( + children: [ + ListTile( + title: Text( + AppLocalizations.of(context).calendar, + style: Theme.of(context).textTheme.headlineMedium, + ), + leading: Icon( + Icons.calendar_today, + color: Theme.of(context).textTheme.headlineMedium!.color, + ), ), - leading: Icon( - Icons.calendar_today, - color: Theme.of(context).textTheme.headlineMedium!.color, + TableCalendar( + locale: Localizations.localeOf(context).languageCode, + firstDay: DateTime.now().subtract(const Duration(days: 1000)), + lastDay: DateTime.now().add(const Duration(days: 365)), + focusedDay: _focusedDay, + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + rangeStartDay: _rangeStart, + rangeEndDay: _rangeEnd, + calendarFormat: CalendarFormat.month, + availableGestures: AvailableGestures.horizontalSwipe, + availableCalendarFormats: const {CalendarFormat.month: ''}, + rangeSelectionMode: _rangeSelectionMode, + eventLoader: _getEventsForDay, + startingDayOfWeek: StartingDayOfWeek.monday, + calendarStyle: getWgerCalendarStyle(Theme.of(context)), + onDaySelected: _onDaySelected, + onRangeSelected: _onRangeSelected, + onPageChanged: (focusedDay) { + _focusedDay = focusedDay; + }, ), - ), - TableCalendar( - locale: Localizations.localeOf(context).languageCode, - firstDay: DateTime.now().subtract(const Duration(days: 1000)), - lastDay: DateTime.now().add(const Duration(days: 365)), - focusedDay: _focusedDay, - selectedDayPredicate: (day) => isSameDay(_selectedDay, day), - rangeStartDay: _rangeStart, - rangeEndDay: _rangeEnd, - calendarFormat: CalendarFormat.month, - availableGestures: AvailableGestures.horizontalSwipe, - availableCalendarFormats: const {CalendarFormat.month: ''}, - rangeSelectionMode: _rangeSelectionMode, - eventLoader: _getEventsForDay, - startingDayOfWeek: StartingDayOfWeek.monday, - calendarStyle: getWgerCalendarStyle(Theme.of(context)), - onDaySelected: _onDaySelected, - onRangeSelected: _onRangeSelected, - onPageChanged: (focusedDay) { - _focusedDay = focusedDay; - }, - ), - const SizedBox(height: 8.0), - ValueListenableBuilder>( - valueListenable: _selectedEvents, - builder: (context, value, _) => Column( - children: [ - ...value.map( - (event) => ListTile( - title: Text( - (() { - switch (event.type) { - case EventType.caloriesDiary: - return AppLocalizations.of(context).nutritionalDiary; - - case EventType.session: - return AppLocalizations.of(context).workoutSession; - - case EventType.weight: - return AppLocalizations.of(context).weight; - - case EventType.measurement: - return AppLocalizations.of(context).measurement; - } - })(), + const SizedBox(height: 8.0), + ValueListenableBuilder>( + valueListenable: _selectedEvents, + builder: (context, value, _) => Column( + children: [ + ...value.map( + (event) => ListTile( + title: Text( + (() { + switch (event.type) { + case EventType.caloriesDiary: + return AppLocalizations.of(context).nutritionalDiary; + + case EventType.session: + return AppLocalizations.of(context).workoutSession; + + case EventType.weight: + return AppLocalizations.of(context).weight; + + case EventType.measurement: + return AppLocalizations.of(context).measurement; + } + })(), + ), + subtitle: Text(event.description), + //onTap: () => print('$event tapped!'), ), - subtitle: Text(event.description), - //onTap: () => print('$event tapped!'), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/widgets/dashboard/widgets/measurements.dart b/lib/widgets/dashboard/widgets/measurements.dart index ed7fbd8f9..ceca4ffb6 100644 --- a/lib/widgets/dashboard/widgets/measurements.dart +++ b/lib/widgets/dashboard/widgets/measurements.dart @@ -18,13 +18,16 @@ import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/measurement.dart'; +import 'package:wger/screens/form_screen.dart'; import 'package:wger/screens/measurement_categories_screen.dart'; +import 'package:wger/theme/theme.dart'; +import 'package:wger/widgets/core/time_range_tab_bar.dart'; import 'package:wger/widgets/dashboard/widgets/nothing_found.dart'; import 'package:wger/widgets/measurements/categories_card.dart'; +import 'package:wger/widgets/measurements/charts.dart'; import 'package:wger/widgets/measurements/forms.dart'; class DashboardMeasurementWidget extends StatefulWidget { @@ -37,108 +40,226 @@ class DashboardMeasurementWidget extends StatefulWidget { class _DashboardMeasurementWidgetState extends State { int _current = 0; final _controller = CarouselSliderController(); + ChartTimeRange _selectedRange = ChartTimeRange.month; @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); + final isDarkMode = Theme.of(context).brightness == Brightness.dark; final items = provider.categories - .map((item) => CategoriesCard(item, elevation: 0)) + .map((item) => CategoriesCard(item, timeRange: _selectedRange, showCard: false)) .toList(); if (items.isNotEmpty) { - items.add( - NothingFound( - AppLocalizations.of(context).moreMeasurementEntries, - AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), - ), - ); + items.add(_buildAddMoreCard(context, isDarkMode)); } return Consumer( - builder: (context, _, __) => Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - AppLocalizations.of(context).measurements, - style: Theme.of(context).textTheme.headlineSmall, - ), - leading: FaIcon( - FontAwesomeIcons.chartLine, - color: Theme.of(context).textTheme.headlineSmall!.color, - ), - // TODO: this icon feels out of place and inconsistent with all - // other dashboard widgets. - // maybe we should just add a "Go to all" at the bottom of the widget - trailing: IconButton( - icon: const Icon(Icons.arrow_forward), - onPressed: () => Navigator.pushNamed( - context, - MeasurementCategoriesScreen.routeName, + builder: (context, _, __) => Padding( + padding: const EdgeInsets.all(4.0), + child: Material( + elevation: 0, + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: isDarkMode ? Theme.of(context).cardColor : Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 4), + spreadRadius: 0, ), - ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ], ), - Column( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (items.isNotEmpty) - Column( + // Header with chevron + Padding( + padding: const EdgeInsets.only(left: 16, right: 8, top: 12, bottom: 12), + child: Row( children: [ - CarouselSlider( - items: items, - carouselController: _controller, - options: CarouselOptions( - autoPlay: false, - enlargeCenterPage: false, - viewportFraction: 1, - enableInfiniteScroll: false, - aspectRatio: 1.1, - onPageChanged: (index, reason) { - setState(() { - _current = index; - }); - }, + Expanded( + child: Text( + AppLocalizations.of(context).measurements, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: items.asMap().entries.map((entry) { - return GestureDetector( - onTap: () => _controller.animateToPage(entry.key), - child: Container( - width: 12.0, - height: 12.0, - margin: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 4.0, - ), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).textTheme.headlineSmall!.color! - .withOpacity( - _current == entry.key ? 0.9 : 0.4, - ), - ), - ), - ); - }).toList(), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => Navigator.pushNamed( + context, + MeasurementCategoriesScreen.routeName, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.chevron_right_rounded, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), ), ), ], - ) - else - NothingFound( - AppLocalizations.of(context).noMeasurementEntries, - AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), ), + ), + // Time range tabs + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TimeRangeTabBar( + selectedRange: _selectedRange, + onRangeChanged: (range) => setState(() => _selectedRange = range), + ), + ), + const SizedBox(height: 16), + // Content + Column( + children: [ + if (items.isNotEmpty) + Column( + children: [ + CarouselSlider( + items: items, + carouselController: _controller, + options: CarouselOptions( + autoPlay: false, + enlargeCenterPage: false, + viewportFraction: 1, + enableInfiniteScroll: false, + aspectRatio: 1.35, + onPageChanged: (index, reason) { + setState(() { + _current = index; + }); + }, + ), + ), + // Line segment indicators + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: items.asMap().entries.map((entry) { + return Expanded( + child: GestureDetector( + onTap: () => _controller.animateToPage(entry.key), + child: Container( + height: 3, + margin: EdgeInsets.only( + right: entry.key < items.length - 1 ? 4 : 0, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: _current == entry.key + ? wgerAccentColor + : (isDarkMode + ? Colors.grey.shade700 + : Colors.grey.shade300), + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ) + else + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: NothingFound( + AppLocalizations.of(context).noMeasurementEntries, + AppLocalizations.of(context).newEntry, + MeasurementCategoryForm(), + ), + ), + ], + ), ], ), - ], + ), ), ), ); } + + Widget _buildAddMoreCard(BuildContext context, bool isDarkMode) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Measuring tape icon + Image.asset( + 'assets/icons/measuring-tape.png', + width: 100, + height: 100, + ), + const SizedBox(height: 16), + // Title + Text( + AppLocalizations.of(context).moreMeasurementEntries, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.grey.shade300 : Colors.grey.shade800, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + // Add button + Material( + color: wgerAccentColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).newEntry, + MeasurementCategoryForm(), + hasListView: true, + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.add_rounded, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context).newEntry, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } } diff --git a/lib/widgets/dashboard/widgets/weight.dart b/lib/widgets/dashboard/widgets/weight.dart index 35d407b20..286a1810e 100644 --- a/lib/widgets/dashboard/widgets/weight.dart +++ b/lib/widgets/dashboard/widgets/weight.dart @@ -17,119 +17,276 @@ */ import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; +import 'package:wger/helpers/measurements.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/user.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/screens/weight_screen.dart'; -import 'package:wger/widgets/dashboard/widgets/nothing_found.dart'; +import 'package:wger/theme/theme.dart'; +import 'package:wger/widgets/core/time_range_tab_bar.dart'; import 'package:wger/widgets/measurements/charts.dart'; -import 'package:wger/widgets/measurements/helpers.dart'; -import 'package:wger/widgets/weight/forms.dart'; +import 'package:wger/widgets/weight/edit_modal.dart'; +import 'package:wger/widgets/weight/entries_modal.dart'; -class DashboardWeightWidget extends StatelessWidget { +class DashboardWeightWidget extends StatefulWidget { const DashboardWeightWidget(); + @override + State createState() => _DashboardWeightWidgetState(); +} + +class _DashboardWeightWidgetState extends State { + ChartTimeRange _selectedRange = ChartTimeRange.month; + + DateTime? _getStartDate() { + final now = DateTime.now(); + switch (_selectedRange) { + case ChartTimeRange.week: + return now.subtract(const Duration(days: 7)); + case ChartTimeRange.month: + return now.subtract(const Duration(days: 30)); + case ChartTimeRange.sixMonths: + return now.subtract(const Duration(days: 182)); + case ChartTimeRange.year: + return now.subtract(const Duration(days: 365)); + case ChartTimeRange.all: + return null; // No start date filter for all-time + } + } + @override Widget build(BuildContext context) { final profile = context.read().profile; final weightProvider = context.read(); + final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final (entriesAll, entries7dAvg) = sensibleRange( - weightProvider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(), - ); + final allEntries = weightProvider.items + .map((e) => MeasurementChartEntry(e.weight, e.date)) + .toList(); + final startDate = _getStartDate(); + // For "all" time range, use all entries; otherwise filter by date + final filteredEntries = startDate != null ? allEntries.whereDate(startDate, null) : allEntries; + final avgDays = getAverageDaysForTimeRange(_selectedRange); + final entriesAvg = movingAverage(filteredEntries, avgDays); return Consumer( - builder: (context, _, __) => Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - AppLocalizations.of(context).weight, - style: Theme.of(context).textTheme.headlineSmall, - ), - leading: FaIcon( - FontAwesomeIcons.weightScale, - color: Theme.of(context).textTheme.headlineSmall!.color, - ), + builder: (context, _, __) => 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.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ], ), - Column( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (weightProvider.items.isNotEmpty) - Column( + // Header with entries button + Padding( + padding: const EdgeInsets.only(left: 16, right: 8, top: 12, bottom: 12), + child: Row( children: [ - SizedBox( - height: 200, - child: MeasurementChartWidgetFl( - entriesAll, - weightUnit(profile!.isMetric, context), - avgs: entries7dAvg, + Expanded( + child: Text( + AppLocalizations.of(context).weight, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), - if (entries7dAvg.isNotEmpty) - MeasurementOverallChangeWidget( - entries7dAvg.first, - entries7dAvg.last, - weightUnit(profile.isMetric, context), + if (weightProvider.items.isNotEmpty) + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => showWeightEntriesModal(context), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.more_vert, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + ), ), - LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text( - AppLocalizations.of(context).goToDetailPage, - overflow: TextOverflow.ellipsis, + ], + ), + ), + // Time range tabs + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TimeRangeTabBar( + selectedRange: _selectedRange, + onRangeChanged: (range) => setState(() => _selectedRange = range), + ), + ), + const SizedBox(height: 12), + // Content + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + children: [ + if (weightProvider.items.isNotEmpty) + Column( + children: [ + SizedBox( + height: 200, + child: MeasurementChartWidgetFl( + filteredEntries, + weightUnit(profile!.isMetric, context), + avgs: entriesAvg, + timeRange: _selectedRange, + ), + ), + const SizedBox(height: 12), + // Bottom row with trend indicator and add button + Row( + children: [ + // Trend indicator + if (entriesAvg != null && entriesAvg.isNotEmpty) + _buildTrendIndicator( + context, + entriesAvg.first, + entriesAvg.last, + weightUnit(profile.isMetric, context), + isDarkMode, + ), + const Spacer(), + // Add button + Material( + color: wgerAccentColor, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => showEditWeightModal( + context, + weightProvider.getNewestEntry()?.copyWith( + id: null, + date: DateTime.now(), + ), + ), + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon( + Icons.add_rounded, + size: 20, + color: Colors.white, + ), ), - onPressed: () { - Navigator.of(context).pushNamed(WeightScreen.routeName); - }, ), - IconButton( - icon: const Icon(Icons.add), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - WeightForm( - weightProvider.getNewestEntry()?.copyWith( - id: null, - date: DateTime.now(), - ), - ), - ), - ); - }, + ), + ], + ), + ], + ) + else + SizedBox( + height: 150, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(AppLocalizations.of(context).noWeightEntries), + const SizedBox(height: 12), + Material( + color: wgerAccentColor, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => showEditWeightModal(context, null), + child: const Padding( + padding: EdgeInsets.all(10), + child: Icon( + Icons.add_rounded, + size: 24, + color: Colors.white, + ), ), - ], + ), ), - ), - ); - }, - ), + ], + ), + ), ], - ) - else - NothingFound( - AppLocalizations.of(context).noWeightEntries, - AppLocalizations.of(context).newEntry, - WeightForm(), ), + ), ], ), - ], + ), ), ), ); } + + Widget _buildTrendIndicator( + BuildContext context, + MeasurementChartEntry first, + MeasurementChartEntry last, + String unit, + bool isDarkMode, + ) { + final delta = last.value - first.value; + final isPositive = delta > 0; + final isNegative = delta < 0; + + // Vibrant accent color for trend indicator + const Color trendColor = wgerAccentColor; + final Color bgColor = wgerAccentColor.withValues(alpha: isDarkMode ? 0.2 : 0.1); + + final IconData trendIcon; + if (isPositive) { + trendIcon = Icons.trending_up_rounded; + } else if (isNegative) { + trendIcon = Icons.trending_down_rounded; + } else { + trendIcon = Icons.trending_flat_rounded; + } + + final prefix = isPositive ? '+' : ''; + final valueText = '$prefix${delta.toStringAsFixed(1)} $unit'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + trendIcon, + size: 16, + color: trendColor, + ), + const SizedBox(width: 4), + Text( + valueText, + style: TextStyle( + color: trendColor, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ); + } } diff --git a/lib/widgets/measurements/categories.dart b/lib/widgets/measurements/categories.dart index 32abf49e6..126e7cd03 100644 --- a/lib/widgets/measurements/categories.dart +++ b/lib/widgets/measurements/categories.dart @@ -21,9 +21,13 @@ import 'package:provider/provider.dart'; import 'package:wger/providers/measurement.dart'; import 'categories_card.dart'; +import 'charts.dart'; class CategoriesList extends StatelessWidget { - const CategoriesList(); + final ChartTimeRange timeRange; + + const CategoriesList({required this.timeRange}); + @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); @@ -33,7 +37,13 @@ class CategoriesList extends StatelessWidget { child: ListView.builder( padding: const EdgeInsets.all(10.0), itemCount: provider.categories.length, - itemBuilder: (context, index) => CategoriesCard(provider.categories[index]), + itemBuilder: (context, index) => SizedBox( + height: 310, + child: CategoriesCard( + provider.categories[index], + timeRange: timeRange, + ), + ), ), ); } diff --git a/lib/widgets/measurements/categories_card.dart b/lib/widgets/measurements/categories_card.dart index 27ef09df2..81fad0c56 100644 --- a/lib/widgets/measurements/categories_card.dart +++ b/lib/widgets/measurements/categories_card.dart @@ -1,94 +1,213 @@ import 'package:flutter/material.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/measurements/measurement_category.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/screens/measurement_entries_screen.dart'; +import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/measurements/helpers.dart'; import 'charts.dart'; -import 'forms.dart'; +import 'edit_modals.dart'; +import 'entries_modal.dart'; class CategoriesCard extends StatelessWidget { final MeasurementCategory currentCategory; - final double? elevation; + final ChartTimeRange? timeRange; + final bool showCard; - const CategoriesCard(this.currentCategory, {this.elevation}); + const CategoriesCard(this.currentCategory, {this.timeRange, this.showCard = true}); + + DateTime? _getStartDate() { + final now = DateTime.now(); + switch (timeRange) { + case ChartTimeRange.week: + return now.subtract(const Duration(days: 7)); + case ChartTimeRange.month: + return now.subtract(const Duration(days: 30)); + case ChartTimeRange.sixMonths: + return now.subtract(const Duration(days: 182)); + case ChartTimeRange.year: + return now.subtract(const Duration(days: 365)); + case ChartTimeRange.all: + return null; // No start date filter for all-time + case null: + return now.subtract(const Duration(days: 30)); + } + } @override Widget build(BuildContext context) { - final (entriesAll, entries7dAvg) = sensibleRange( - currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(), - ); + final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return Card( - elevation: elevation, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 5), - child: Text( - currentCategory.name, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - Container( - padding: const EdgeInsets.all(10), - height: 220, - child: MeasurementChartWidgetFl( - entriesAll, - currentCategory.unit, - avgs: entries7dAvg, - ), + final allEntries = currentCategory.entries + .map((e) => MeasurementChartEntry(e.value, e.date)) + .toList(); + final startDate = _getStartDate(); + // For "all" time range, use all entries; otherwise filter by date + final filteredEntries = startDate != null + ? allEntries + .where((e) => e.date.isAfter(startDate) || e.date.isAtSameMomentAs(startDate)) + .toList() + : allEntries; + final avgDays = getAverageDaysForTimeRange(timeRange); + final entriesAvg = movingAverage(filteredEntries, avgDays); + + final content = Column( + children: [ + // Category name (tappable to view entries) + GestureDetector( + onTap: () => showEntriesModal(context, currentCategory), + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + currentCategory.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.grey.shade300 : Colors.grey.shade700, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.expand_more_rounded, + size: 20, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade500, + ), + ], ), - if (entries7dAvg.isNotEmpty) - MeasurementOverallChangeWidget( - entries7dAvg.first, - entries7dAvg.last, + ), + ), + // Chart - uses Expanded to fill available space in constrained contexts + Expanded( + child: MeasurementChartWidgetFl( + filteredEntries, + currentCategory.unit, + avgs: entriesAvg, + timeRange: timeRange, + ), + ), + const SizedBox(height: 12), + // Bottom row with trend indicator and add button + Row( + children: [ + // Trend indicator + if (entriesAvg != null && entriesAvg.isNotEmpty) + _buildTrendIndicator( + context, + entriesAvg.first, + entriesAvg.last, currentCategory.unit, + isDarkMode, ), - const Divider(), - LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text(AppLocalizations.of(context).goToDetailPage), - onPressed: () { - Navigator.pushNamed( - context, - MeasurementEntriesScreen.routeName, - arguments: currentCategory.id, - ); - }, - ), - IconButton( - onPressed: () async { - await Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - MeasurementEntryForm(currentCategory.id!), - ), - ); - }, - icon: const Icon(Icons.add), - ), - ], - ), + const Spacer(), + // Add button + Material( + color: wgerAccentColor, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => showEditEntryModal(context, currentCategory, null), + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon( + Icons.add_rounded, + size: 20, + color: Colors.white, ), - ); - }, + ), + ), ), ], ), + ], + ); + + // When used inside dashboard (showCard=false), just return content with padding + if (!showCard) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: content, + ); + } + + // When used in detail page (showCard=true), wrap in card container + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDarkMode ? Theme.of(context).cardColor : Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ], + ), + child: content, + ), + ); + } + + Widget _buildTrendIndicator( + BuildContext context, + MeasurementChartEntry first, + MeasurementChartEntry last, + String unit, + bool isDarkMode, + ) { + final delta = last.value - first.value; + final isPositive = delta > 0; + final isNegative = delta < 0; + + const Color trendColor = wgerAccentColor; + final Color bgColor = wgerAccentColor.withValues(alpha: isDarkMode ? 0.2 : 0.1); + + final IconData trendIcon; + if (isPositive) { + trendIcon = Icons.trending_up_rounded; + } else if (isNegative) { + trendIcon = Icons.trending_down_rounded; + } else { + trendIcon = Icons.trending_flat_rounded; + } + + final prefix = isPositive ? '+' : ''; + final valueText = '$prefix${delta.toStringAsFixed(1)} $unit'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + trendIcon, + size: 16, + color: trendColor, + ), + const SizedBox(width: 4), + Text( + valueText, + style: const TextStyle( + color: trendColor, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], ), ); } diff --git a/lib/widgets/measurements/charts.dart b/lib/widgets/measurements/charts.dart index 407b1a9f4..d24f8954f 100644 --- a/lib/widgets/measurements/charts.dart +++ b/lib/widgets/measurements/charts.dart @@ -19,9 +19,12 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:wger/helpers/charts.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/theme/theme.dart'; + +/// Time range options for chart display +enum ChartTimeRange { week, month, sixMonths, year, all } class MeasurementOverallChangeWidget extends StatelessWidget { final MeasurementChartEntry _first; @@ -56,8 +59,9 @@ class MeasurementChartWidgetFl extends StatefulWidget { final List _entries; final List? avgs; final String _unit; + final ChartTimeRange? timeRange; - const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs}); + const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs, this.timeRange}); @override State createState() => _MeasurementChartWidgetFlState(); @@ -76,9 +80,17 @@ class _MeasurementChartWidgetFlState extends State { } LineTouchData tooltipData() { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + return LineTouchData( touchTooltipData: LineTouchTooltipData( - getTooltipColor: (touchedSpot) => Theme.of(context).colorScheme.primaryContainer, + tooltipBorderRadius: BorderRadius.circular(12), + tooltipPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + getTooltipColor: (touchedSpot) => isDarkMode ? Colors.grey.shade800 : Colors.white, + tooltipBorder: BorderSide( + color: isDarkMode ? Colors.grey.shade700 : Colors.grey.shade200, + width: 1, + ), getTooltipItems: (touchedSpots) { final numberFormat = NumberFormat.decimalPattern( Localizations.localeOf(context).toString(), @@ -87,40 +99,263 @@ class _MeasurementChartWidgetFlState extends State { return touchedSpots.map((touchedSpot) { final msSinceEpoch = touchedSpot.x.toInt(); final DateTime date = DateTime.fromMillisecondsSinceEpoch(touchedSpot.x.toInt()); - final dateStr = DateFormat.Md( + final dateStr = DateFormat.yMd( Localizations.localeOf(context).languageCode, ).format(date); // Check if this is an interpolated point (milliseconds ending with 123) final bool isInterpolated = msSinceEpoch % 1000 == INTERPOLATION_MARKER; - final String interpolatedMarker = isInterpolated ? ' (interpolated)' : ''; + final String interpolatedMarker = isInterpolated ? ' (est.)' : ''; + + // Use the bar's color for the tooltip text + // barIndex 0 = main data line (wgerAccentColor) + // barIndex 1 = average trend line (orange) + final isAverageLine = touchedSpot.barIndex == 1; + final lineColor = isAverageLine ? const Color(0xFFFF8C00) : wgerAccentColor; return LineTooltipItem( - '$dateStr: ${numberFormat.format(touchedSpot.y)} ${widget._unit}$interpolatedMarker', - TextStyle(color: touchedSpot.bar.color), + '${numberFormat.format(touchedSpot.y)} ${widget._unit}$interpolatedMarker\n', + TextStyle( + color: lineColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + children: [ + TextSpan( + text: dateStr, + style: TextStyle( + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + fontWeight: FontWeight.normal, + fontSize: 12, + ), + ), + ], ); }).toList(); }, ), + handleBuiltInTouches: true, + getTouchedSpotIndicator: (barData, spotIndexes) { + return spotIndexes.map((spotIndex) { + return TouchedSpotIndicatorData( + const FlLine(color: Colors.transparent), + FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) { + return FlDotCirclePainter( + radius: 6, + color: wgerAccentColor, + strokeWidth: 3, + strokeColor: Theme.of(context).brightness == Brightness.light + ? Colors.white + : Theme.of(context).cardColor, + ); + }, + ), + ); + }).toList(); + }, ); } + /// Get the fixed min/max x-axis bounds based on the time range + (double, double) _getFixedXAxisBounds() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + switch (widget.timeRange) { + case ChartTimeRange.week: + final startOfWeek = today.subtract(const Duration(days: 6)); + return ( + startOfWeek.millisecondsSinceEpoch.toDouble(), + today.add(const Duration(hours: 23, minutes: 59)).millisecondsSinceEpoch.toDouble(), + ); + case ChartTimeRange.month: + final startDate = today.subtract(const Duration(days: 30)); + return ( + startDate.millisecondsSinceEpoch.toDouble(), + today.add(const Duration(hours: 23, minutes: 59)).millisecondsSinceEpoch.toDouble(), + ); + case ChartTimeRange.sixMonths: + final startDate = DateTime(now.year, now.month - 5, 1); + final endDate = DateTime(now.year, now.month + 1, 0); + return ( + startDate.millisecondsSinceEpoch.toDouble(), + endDate.millisecondsSinceEpoch.toDouble(), + ); + case ChartTimeRange.year: + final startDate = DateTime(now.year, now.month - 11, 1); + final endDate = DateTime(now.year, now.month + 1, 0); + return ( + startDate.millisecondsSinceEpoch.toDouble(), + endDate.millisecondsSinceEpoch.toDouble(), + ); + case ChartTimeRange.all: + case null: + // Fall back to data-based bounds + if (widget._entries.isEmpty) { + return ( + today.subtract(const Duration(days: 7)).millisecondsSinceEpoch.toDouble(), + today.millisecondsSinceEpoch.toDouble(), + ); + } + return ( + widget._entries + .map((e) => e.date.millisecondsSinceEpoch) + .reduce((a, b) => a < b ? a : b) + .toDouble(), + widget._entries + .map((e) => e.date.millisecondsSinceEpoch) + .reduce((a, b) => a > b ? a : b) + .toDouble(), + ); + } + } + + /// Get the vertical line positions for the grid based on time range + List _getVerticalLinePositions() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final positions = []; + + switch (widget.timeRange) { + case ChartTimeRange.week: + // One line per day for the last 7 days + for (int i = 6; i >= 0; i--) { + final day = today.subtract(Duration(days: i)); + positions.add(day.millisecondsSinceEpoch.toDouble()); + } + break; + case ChartTimeRange.month: + // Lines for each Monday in the last 30 days + for (int i = 30; i >= 0; i--) { + final day = today.subtract(Duration(days: i)); + if (day.weekday == DateTime.monday) { + positions.add(day.millisecondsSinceEpoch.toDouble()); + } + } + break; + case ChartTimeRange.sixMonths: + // One line per month for the last 6 months + for (int i = 5; i >= 0; i--) { + final monthStart = DateTime(now.year, now.month - i, 1); + positions.add(monthStart.millisecondsSinceEpoch.toDouble()); + } + break; + case ChartTimeRange.year: + // One line per month for the last 12 months + for (int i = 11; i >= 0; i--) { + final monthStart = DateTime(now.year, now.month - i, 1); + positions.add(monthStart.millisecondsSinceEpoch.toDouble()); + } + break; + case ChartTimeRange.all: + case null: + // No fixed positions, use default grid behavior + break; + } + + return positions; + } + + /// Get the x-axis interval for labels + double _getXAxisInterval() { + switch (widget.timeRange) { + case ChartTimeRange.week: + return const Duration(days: 1).inMilliseconds.toDouble(); + case ChartTimeRange.month: + return const Duration(days: 7).inMilliseconds.toDouble(); + case ChartTimeRange.sixMonths: + case ChartTimeRange.year: + // Approximate month interval + return const Duration(days: 30).inMilliseconds.toDouble(); + case ChartTimeRange.all: + case null: + if (widget._entries.isEmpty) { + return CHART_MILLISECOND_FACTOR; + } + final first = widget._entries.map((e) => e.date).reduce((a, b) => a.isBefore(b) ? a : b); + final last = widget._entries.map((e) => e.date).reduce((a, b) => a.isAfter(b) ? a : b); + final diff = last.difference(first).inMilliseconds; + return diff == 0 ? CHART_MILLISECOND_FACTOR : diff / 3; + } + } + + /// Get the vertical grid interval - use small interval so checkToShowVerticalLine can filter + double _getVerticalInterval() { + switch (widget.timeRange) { + case ChartTimeRange.week: + return const Duration(days: 1).inMilliseconds.toDouble(); + case ChartTimeRange.month: + // Check each day so we can filter to show only Mondays + return const Duration(days: 1).inMilliseconds.toDouble(); + case ChartTimeRange.sixMonths: + case ChartTimeRange.year: + // Check each day so we can filter to show only month starts + return const Duration(days: 1).inMilliseconds.toDouble(); + case ChartTimeRange.all: + case null: + return const Duration(days: 7).inMilliseconds.toDouble(); + } + } + + /// Get the label for a given x value based on time range + String _getXAxisLabel(double value) { + final date = DateTime.fromMillisecondsSinceEpoch(value.toInt()); + final locale = Localizations.localeOf(context).languageCode; + + switch (widget.timeRange) { + case ChartTimeRange.week: + // Weekday name (Mon, Tue, etc.) + return DateFormat.E(locale).format(date); + case ChartTimeRange.month: + // Date (e.g., "12/5" or "5.12") + return DateFormat.Md(locale).format(date); + case ChartTimeRange.sixMonths: + // Month name (Jan, Feb, etc.) + return DateFormat.MMM(locale).format(date); + case ChartTimeRange.year: + // Month number (01, 02, ..., 12) + return date.month.toString().padLeft(2, '0'); + case ChartTimeRange.all: + case null: + // Default behavior + return DateFormat.Md(locale).format(date); + } + } + LineChartData mainData() { final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + final gridColor = Theme.of(context).brightness == Brightness.light + ? wgerChartGridColor + : wgerChartGridColorDark; + + final (minX, maxX) = _getFixedXAxisBounds(); + final verticalLinePositions = _getVerticalLinePositions(); return LineChartData( + minX: minX, + maxX: maxX, lineTouchData: tooltipData(), gridData: FlGridData( show: true, drawVerticalLine: true, - // horizontalInterval: 1, - // verticalInterval: 1, getDrawingHorizontalLine: (value) { - return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1); + return FlLine(color: gridColor, strokeWidth: 1); }, getDrawingVerticalLine: (value) { - return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1); + return FlLine(color: gridColor, strokeWidth: 1, dashArray: [5, 5]); }, + checkToShowVerticalLine: (value) { + // When using fixed time range, only show lines at specific positions + if (widget.timeRange != null && verticalLinePositions.isNotEmpty) { + return verticalLinePositions.any( + (pos) => (value - pos).abs() < const Duration(hours: 12).inMilliseconds, + ); + } + return true; + }, + verticalInterval: widget.timeRange != null ? _getVerticalInterval() : null, ), titlesData: FlTitlesData( show: true, @@ -133,15 +368,25 @@ class _MeasurementChartWidgetFlState extends State { bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, + reservedSize: 30, getTitlesWidget: (value, meta) { - // Don't show the first and last entries, to avoid overlap - // see https://stackoverflow.com/questions/73355777/flutter-fl-chart-how-can-we-avoid-the-overlap-of-the-ordinate - // this is needlessly aggressive if the titles are "sparse", but we should optimize for more busy data + // Don't show labels at the exact min/max to avoid overlap if (value == meta.min || value == meta.max) { - return const Text(''); + return const SizedBox.shrink(); } + + if (widget.timeRange != null) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _getXAxisLabel(value), + style: const TextStyle(fontSize: 11), + ), + ); + } + + // Default behavior for no time range final DateTime date = DateTime.fromMillisecondsSinceEpoch(value.toInt()); - // if we go across years, show years in the ticks. otherwise leave them out if (DateTime.fromMillisecondsSinceEpoch(meta.min.toInt()).year != DateTime.fromMillisecondsSinceEpoch(meta.max.toInt()).year) { return Text( @@ -152,36 +397,33 @@ class _MeasurementChartWidgetFlState extends State { DateFormat.Md(Localizations.localeOf(context).languageCode).format(date), ); }, - interval: widget._entries.isNotEmpty - ? chartGetInterval( - widget._entries.last.date, - widget._entries.first.date, - ) - : CHART_MILLISECOND_FACTOR, + interval: _getXAxisInterval(), ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 65, + reservedSize: 45, getTitlesWidget: (value, meta) { // Don't show the first and last entries, to avoid overlap - // see https://stackoverflow.com/questions/73355777/flutter-fl-chart-how-can-we-avoid-the-overlap-of-the-ordinate - // this is needlessly aggressive if the titles are "sparse", but we should optimize for more busy data if (value == meta.min || value == meta.max) { return const Text(''); } - return Text('${numberFormat.format(value)} ${widget._unit}'); + return Text( + '${numberFormat.format(value)} ${widget._unit}', + style: const TextStyle(fontSize: 11), + ); }, ), ), ), borderData: FlBorderData( show: true, - border: Border.all(color: Theme.of(context).colorScheme.primaryContainer), + border: Border.all(color: gridColor), ), lineBarsData: [ + // Main data line (drawn first so gradient is behind the trend line) LineChartBarData( spots: widget._entries .map( @@ -192,11 +434,35 @@ class _MeasurementChartWidgetFlState extends State { ) .toList(), isCurved: false, - color: Theme.of(context).colorScheme.primary, - barWidth: 0, + color: wgerAccentColor, + barWidth: 3, isStrokeCapRound: true, - dotData: const FlDotData(show: true), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + wgerAccentColor.withValues(alpha: 0.3), + wgerAccentColor.withValues(alpha: 0.0), + ], + ), + ), + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) { + return FlDotCirclePainter( + radius: 4, + color: Theme.of(context).brightness == Brightness.light + ? Colors.white + : Theme.of(context).cardColor, + strokeWidth: 2.5, + strokeColor: wgerAccentColor, + ); + }, + ), ), + // Average trend line (drawn on top) if (widget.avgs != null) LineChartBarData( spots: widget.avgs! @@ -208,8 +474,10 @@ class _MeasurementChartWidgetFlState extends State { ) .toList(), isCurved: false, - color: Theme.of(context).colorScheme.tertiary, - barWidth: 1, + color: const Color(0xFFFF8C00), + barWidth: 2, + isStrokeCapRound: true, + dashArray: [6, 4], dotData: const FlDotData(show: false), ), ], @@ -224,8 +492,29 @@ class MeasurementChartEntry { MeasurementChartEntry(this.value, this.date); } -// for each point, return the average of all the points in the 7 days preceding it -List moving7dAverage(List vals) { +/// Returns the moving average window in days for each time range +int? getAverageDaysForTimeRange(ChartTimeRange? timeRange) { + switch (timeRange) { + case ChartTimeRange.week: + return null; // No average for week view + case ChartTimeRange.month: + return 7; + case ChartTimeRange.sixMonths: + return 14; + case ChartTimeRange.year: + return 30; + case ChartTimeRange.all: + return 7; // Use 7-day average for all-time view + case null: + return 7; // Default to 7-day average + } +} + +/// For each point, return the average of all points in the preceding [days] window. +/// Returns null if [days] is null (no averaging). +List? movingAverage(List vals, int? days) { + if (days == null) return null; + var start = 0; var end = 0; final List out = []; @@ -237,8 +526,8 @@ List moving7dAverage(List vals) { // since users can log measurements several days, or minutes apart, // we can't make assumptions. We have to manually advance 'start' // such that it is always the first point within our desired range. - // posibly start == end (when there is only one point in the range) - final intervalStart = vals[end].date.subtract(const Duration(days: 7)); + // possibly start == end (when there is only one point in the range) + final intervalStart = vals[end].date.subtract(Duration(days: days)); while (start < end && vals[start].date.isBefore(intervalStart)) { start++; } @@ -252,6 +541,11 @@ List moving7dAverage(List vals) { return out; } +// Keep for backwards compatibility +List moving7dAverage(List vals) { + return movingAverage(vals, 7) ?? []; +} + class Indicator extends StatelessWidget { const Indicator({ super.key, diff --git a/lib/widgets/measurements/edit_modals.dart b/lib/widgets/measurements/edit_modals.dart new file mode 100644 index 000000000..e2fd3bbc4 --- /dev/null +++ b/lib/widgets/measurements/edit_modals.dart @@ -0,0 +1,607 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/helpers/consts.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/measurements/measurement_category.dart'; +import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/providers/measurement.dart'; +import 'package:wger/theme/theme.dart'; + +/// Shows a modern modal bottom sheet for editing a measurement entry +void showEditEntryModal( + BuildContext context, + MeasurementCategory category, + MeasurementEntry? entry, +) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final isNewEntry = entry == null; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _EditEntryModalContent( + category: category, + entry: entry, + isDarkMode: isDarkMode, + isNewEntry: isNewEntry, + ), + ); +} + +class _EditEntryModalContent extends StatefulWidget { + final MeasurementCategory category; + final MeasurementEntry? entry; + final bool isDarkMode; + final bool isNewEntry; + + const _EditEntryModalContent({ + required this.category, + required this.entry, + required this.isDarkMode, + required this.isNewEntry, + }); + + @override + State<_EditEntryModalContent> createState() => _EditEntryModalContentState(); +} + +class _EditEntryModalContentState extends State<_EditEntryModalContent> { + final _formKey = GlobalKey(); + late TextEditingController _valueController; + late TextEditingController _dateController; + late TextEditingController _notesController; + late DateTime _selectedDate; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _selectedDate = widget.entry?.date ?? DateTime.now(); + _valueController = TextEditingController(); + _dateController = TextEditingController(); + _notesController = TextEditingController(text: widget.entry?.notes ?? ''); + } + + @override + void dispose() { + _valueController.dispose(); + _dateController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat.yMMMd(Localizations.localeOf(context).languageCode); + final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + + if (_dateController.text.isEmpty) { + _dateController.text = dateFormat.format(_selectedDate); + } + if (_valueController.text.isEmpty && widget.entry?.value != null) { + _valueController.text = numberFormat.format(widget.entry!.value); + } + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: BoxDecoration( + color: widget.isDarkMode ? Theme.of(context).scaffoldBackgroundColor : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Handle bar + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: widget.isDarkMode ? Colors.grey.shade600 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title + Text( + widget.isNewEntry + ? AppLocalizations.of(context).newEntry + : AppLocalizations.of(context).edit, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + // Date field + _buildTextField( + controller: _dateController, + label: AppLocalizations.of(context).date, + readOnly: true, + suffixIcon: Icons.calendar_today_rounded, + onTap: () async { + final pickedDate = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(DateTime.now().year - 10), + lastDate: DateTime.now(), + ); + if (pickedDate != null) { + setState(() { + _selectedDate = pickedDate; + _dateController.text = dateFormat.format(pickedDate); + }); + } + }, + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context).enterValue; + } + return null; + }, + ), + const SizedBox(height: 16), + // Value field + _buildTextField( + controller: _valueController, + label: AppLocalizations.of(context).value, + keyboardType: textInputTypeDecimal, + suffixText: widget.category.unit, + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context).enterValue; + } + try { + numberFormat.parse(value); + } catch (error) { + return AppLocalizations.of(context).enterValidNumber; + } + return null; + }, + ), + const SizedBox(height: 16), + // Notes field + _buildTextField( + controller: _notesController, + label: AppLocalizations.of(context).notes, + maxLines: 2, + validator: (value) { + if (value != null && value.length > 100) { + return AppLocalizations.of(context).enterCharacters('0', '100'); + } + return null; + }, + ), + const SizedBox(height: 24), + // Save button + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _saveEntry, + style: ElevatedButton.styleFrom( + backgroundColor: wgerAccentColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + AppLocalizations.of(context).save, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + bool readOnly = false, + TextInputType? keyboardType, + IconData? suffixIcon, + String? suffixText, + int maxLines = 1, + VoidCallback? onTap, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + readOnly: readOnly, + keyboardType: keyboardType, + maxLines: maxLines, + onTap: onTap, + validator: validator, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(fontSize: 13), + filled: true, + fillColor: widget.isDarkMode + ? Colors.grey.shade800.withValues(alpha: 0.5) + : Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: wgerAccentColor, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Theme.of(context).colorScheme.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Theme.of(context).colorScheme.error, width: 2), + ), + suffixIcon: suffixIcon != null ? Icon(suffixIcon, color: wgerAccentColor, size: 18) : null, + suffixText: suffixText, + suffixStyle: TextStyle( + color: widget.isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ); + } + + Future _saveEntry() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + final value = numberFormat.parse(_valueController.text); + final provider = Provider.of(context, listen: false); + + try { + if (widget.isNewEntry) { + await provider.addEntry( + MeasurementEntry( + id: null, + category: widget.category.id!, + date: _selectedDate, + value: value, + notes: _notesController.text, + ), + ); + } else { + await provider.editEntry( + widget.entry!.id!, + widget.entry!.category, + value, + _notesController.text, + _selectedDate, + ); + } + + if (mounted) { + Navigator.pop(context); + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).anErrorOccurred, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} + +/// Shows a modern modal bottom sheet for editing a measurement category +void showEditCategoryModal( + BuildContext context, + MeasurementCategory? category, +) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final isNewCategory = category == null; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _EditCategoryModalContent( + category: category, + isDarkMode: isDarkMode, + isNewCategory: isNewCategory, + ), + ); +} + +class _EditCategoryModalContent extends StatefulWidget { + final MeasurementCategory? category; + final bool isDarkMode; + final bool isNewCategory; + + const _EditCategoryModalContent({ + required this.category, + required this.isDarkMode, + required this.isNewCategory, + }); + + @override + State<_EditCategoryModalContent> createState() => _EditCategoryModalContentState(); +} + +class _EditCategoryModalContentState extends State<_EditCategoryModalContent> { + final _formKey = GlobalKey(); + late TextEditingController _nameController; + late TextEditingController _unitController; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.category?.name ?? ''); + _unitController = TextEditingController(text: widget.category?.unit ?? ''); + } + + @override + void dispose() { + _nameController.dispose(); + _unitController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: BoxDecoration( + color: widget.isDarkMode ? Theme.of(context).scaffoldBackgroundColor : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Handle bar + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: widget.isDarkMode ? Colors.grey.shade600 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title + Text( + widget.isNewCategory + ? AppLocalizations.of(context).newEntry + : AppLocalizations.of(context).edit, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + // Name field + _buildTextField( + controller: _nameController, + label: AppLocalizations.of(context).name, + helperText: AppLocalizations.of(context).measurementCategoriesHelpText, + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context).enterValue; + } + return null; + }, + ), + const SizedBox(height: 16), + // Unit field + _buildTextField( + controller: _unitController, + label: AppLocalizations.of(context).unit, + helperText: AppLocalizations.of(context).measurementEntriesHelpText, + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context).enterValue; + } + return null; + }, + ), + const SizedBox(height: 24), + // Save button + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _saveCategory, + style: ElevatedButton.styleFrom( + backgroundColor: wgerAccentColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + AppLocalizations.of(context).save, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + String? helperText, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + validator: validator, + decoration: InputDecoration( + labelText: label, + helperText: helperText, + helperMaxLines: 2, + filled: true, + fillColor: widget.isDarkMode + ? Colors.grey.shade800.withValues(alpha: 0.5) + : Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: wgerAccentColor, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Theme.of(context).colorScheme.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Theme.of(context).colorScheme.error, width: 2), + ), + ), + ); + } + + Future _saveCategory() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + final provider = Provider.of(context, listen: false); + + try { + if (widget.isNewCategory) { + await provider.addCategory( + MeasurementCategory( + id: null, + name: _nameController.text, + unit: _unitController.text, + ), + ); + } else { + await provider.editCategory( + widget.category!.id!, + _nameController.text, + _unitController.text, + ); + } + + if (mounted) { + Navigator.pop(context); + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).anErrorOccurred, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} diff --git a/lib/widgets/measurements/entries.dart b/lib/widgets/measurements/entries.dart deleted file mode 100644 index 6573375f8..000000000 --- a/lib/widgets/measurements/entries.dart +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/measurements/measurement_category.dart'; -import 'package:wger/providers/measurement.dart'; -import 'package:wger/providers/nutrition.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/widgets/measurements/charts.dart'; -import 'package:wger/widgets/measurements/helpers.dart'; - -import 'forms.dart'; - -class EntriesList extends StatelessWidget { - final MeasurementCategory _category; - - const EntriesList(this._category); - - @override - Widget build(BuildContext context) { - final plans = Provider.of(context, listen: false).items; - final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); - final provider = Provider.of(context, listen: false); - - final entriesAll = _category.entries - .map((e) => MeasurementChartEntry(e.value, e.date)) - .toList(); - final entries7dAvg = moving7dAverage(entriesAll); - - return Column( - children: [ - ...getOverviewWidgetsSeries( - _category.name, - entriesAll, - entries7dAvg, - plans, - _category.unit, - context, - ), - SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.all(10.0), - itemCount: _category.entries.length, - itemBuilder: (context, index) { - final currentEntry = _category.entries[index]; - - return Card( - child: ListTile( - title: Text('${numberFormat.format(currentEntry.value)} ${_category.unit}'), - subtitle: Text( - DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(currentEntry.date), - ), - trailing: PopupMenuButton( - itemBuilder: (BuildContext context) { - return [ - PopupMenuItem( - child: Text(AppLocalizations.of(context).edit), - onTap: () => Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).edit, - MeasurementEntryForm( - currentEntry.category, - currentEntry, - ), - ), - ), - ), - PopupMenuItem( - child: Text(AppLocalizations.of(context).delete), - onTap: () async { - // Delete entry from DB - await provider.deleteEntry( - currentEntry.id!, - currentEntry.category, - ); - - // and inform the user - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).successfullyDeleted, - textAlign: TextAlign.center, - ), - ), - ); - } - }, - ), - ]; - }, - ), - ), - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/widgets/measurements/entries_modal.dart b/lib/widgets/measurements/entries_modal.dart new file mode 100644 index 000000000..ab6b868b8 --- /dev/null +++ b/lib/widgets/measurements/entries_modal.dart @@ -0,0 +1,485 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/measurements/measurement_category.dart'; +import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/providers/measurement.dart'; +import 'package:wger/theme/theme.dart'; + +import 'edit_modals.dart'; + +/// Shows a modal bottom sheet with all entries for a measurement category +void showEntriesModal(BuildContext context, MeasurementCategory category) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) => Container( + decoration: BoxDecoration( + color: isDarkMode ? Theme.of(context).scaffoldBackgroundColor : Colors.grey.shade50, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey.shade600 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 8, 4), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.name, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Consumer( + builder: (context, provider, _) { + final currentCategory = provider.findCategoryById(category.id!); + return Text( + '${currentCategory.entries.length} ${AppLocalizations.of(context).entries}', + style: TextStyle( + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + fontSize: 14, + ), + ); + }, + ), + ], + ), + ), + // Category action buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Edit button + Material( + color: wgerAccentColor.withValues(alpha: isDarkMode ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () { + Navigator.pop(context); + showEditCategoryModal(context, category); + }, + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon( + Icons.edit_outlined, + size: 20, + color: wgerAccentColor, + ), + ), + ), + ), + const SizedBox(width: 8), + // Delete button + Material( + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: isDarkMode ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => _showDeleteCategoryDialog(context, category), + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon( + Icons.delete_outline, + size: 20, + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ), + ], + ), + ], + ), + ), + // Divider + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + color: isDarkMode ? Colors.grey.shade700 : Colors.grey.shade300, + height: 24, + ), + ), + // Entries list + Expanded( + child: Consumer( + builder: (context, provider, _) { + final currentCategory = provider.findCategoryById(category.id!); + final entries = currentCategory.entries; + + if (entries.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: wgerAccentColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.straighten_outlined, + size: 48, + color: wgerAccentColor, + ), + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context).noMeasurementEntries, + style: TextStyle( + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + return _EntryTile( + entry: entry, + category: currentCategory, + isDarkMode: isDarkMode, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); +} + +void _showDeleteCategoryDialog(BuildContext context, MeasurementCategory category) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text(AppLocalizations.of(context).delete), + content: Text(AppLocalizations.of(context).confirmDelete(category.name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text( + MaterialLocalizations.of(context).cancelButtonLabel, + style: TextStyle(color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600), + ), + ), + TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error.withValues(alpha: 0.1), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () async { + try { + await Provider.of( + context, + listen: false, + ).deleteCategory(category.id!); + if (context.mounted) { + Navigator.pop(dialogContext); // Close dialog + Navigator.pop(context); // Close modal + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).successfullyDeleted, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + } catch (error) { + if (context.mounted) { + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).anErrorOccurred, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + child: Text( + AppLocalizations.of(context).delete, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); +} + +class _EntryTile extends StatelessWidget { + final MeasurementEntry entry; + final MeasurementCategory category; + final bool isDarkMode; + + const _EntryTile({ + required this.entry, + required this.category, + required this.isDarkMode, + }); + + @override + Widget build(BuildContext context) { + final numberFormat = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final dateFormat = DateFormat.yMMMd( + Localizations.localeOf(context).languageCode, + ); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isDarkMode ? Theme.of(context).cardColor : Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Value indicator + Container( + width: 4, + height: 40, + decoration: BoxDecoration( + color: wgerAccentColor.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 16), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + numberFormat.format(entry.value), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const SizedBox(width: 4), + Text( + category.unit, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + dateFormat.format(entry.date), + style: TextStyle( + color: isDarkMode ? Colors.grey.shade500 : Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + ), + // Action buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Edit button + Material( + color: wgerAccentColor.withValues(alpha: isDarkMode ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => showEditEntryModal(context, category, entry), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.edit_outlined, + size: 18, + color: wgerAccentColor, + ), + ), + ), + ), + const SizedBox(width: 8), + // Delete button + Material( + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: isDarkMode ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => _showDeleteEntryDialog(context, entry), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.delete_outline, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _showDeleteEntryDialog(BuildContext context, MeasurementEntry entry) { + final numberFormat = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final dateFormat = DateFormat.yMMMd( + Localizations.localeOf(context).languageCode, + ); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text(AppLocalizations.of(context).delete), + content: Text( + AppLocalizations.of(context).confirmDelete( + '${numberFormat.format(entry.value)} ${category.unit} (${dateFormat.format(entry.date)})', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text( + MaterialLocalizations.of(context).cancelButtonLabel, + style: TextStyle(color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600), + ), + ), + TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error.withValues(alpha: 0.1), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () async { + try { + await Provider.of( + context, + listen: false, + ).deleteEntry(entry.id!, entry.category); + if (context.mounted) { + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).successfullyDeleted, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + } catch (error) { + if (context.mounted) { + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).anErrorOccurred, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + child: Text( + AppLocalizations.of(context).delete, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/weight/edit_modal.dart b/lib/widgets/weight/edit_modal.dart new file mode 100644 index 000000000..220805ee0 --- /dev/null +++ b/lib/widgets/weight/edit_modal.dart @@ -0,0 +1,456 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/helpers/consts.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/body_weight/weight_entry.dart'; +import 'package:wger/providers/body_weight.dart'; +import 'package:wger/theme/theme.dart'; + +/// Shows a modern modal bottom sheet for adding/editing a weight entry +void showEditWeightModal(BuildContext context, WeightEntry? entry) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final isNewEntry = entry == null || entry.id == null; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _EditWeightModalContent( + entry: entry, + isDarkMode: isDarkMode, + isNewEntry: isNewEntry, + ), + ); +} + +class _EditWeightModalContent extends StatefulWidget { + final WeightEntry? entry; + final bool isDarkMode; + final bool isNewEntry; + + const _EditWeightModalContent({ + required this.entry, + required this.isDarkMode, + required this.isNewEntry, + }); + + @override + State<_EditWeightModalContent> createState() => _EditWeightModalContentState(); +} + +class _EditWeightModalContentState extends State<_EditWeightModalContent> { + final _formKey = GlobalKey(); + late TextEditingController _weightController; + late TextEditingController _dateController; + late TextEditingController _timeController; + late DateTime _selectedDate; + late TimeOfDay _selectedTime; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _selectedDate = widget.entry?.date ?? now; + _selectedTime = TimeOfDay.fromDateTime(_selectedDate); + _weightController = TextEditingController(); + _dateController = TextEditingController(); + _timeController = TextEditingController(); + } + + @override + void dispose() { + _weightController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat.yMMMd(Localizations.localeOf(context).languageCode); + final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + + if (_dateController.text.isEmpty) { + _dateController.text = dateFormat.format(_selectedDate); + } + if (_timeController.text.isEmpty) { + _timeController.text = _selectedTime.format(context); + } + if (_weightController.text.isEmpty && + widget.entry?.weight != null && + widget.entry!.weight != 0) { + _weightController.text = numberFormat.format(widget.entry!.weight); + } + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: BoxDecoration( + color: widget.isDarkMode ? Theme.of(context).scaffoldBackgroundColor : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Handle bar + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: widget.isDarkMode ? Colors.grey.shade600 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title + Text( + widget.isNewEntry + ? AppLocalizations.of(context).newEntry + : AppLocalizations.of(context).edit, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + // Date and Time fields in a row + Row( + children: [ + Expanded( + child: _buildTextField( + controller: _dateController, + label: AppLocalizations.of(context).date, + readOnly: true, + suffixIcon: Icons.calendar_today_rounded, + onTap: () async { + final pickedDate = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(DateTime.now().year - 10), + lastDate: DateTime.now(), + ); + if (pickedDate != null) { + setState(() { + _selectedDate = pickedDate; + _dateController.text = dateFormat.format(pickedDate); + }); + } + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildTextField( + controller: _timeController, + label: AppLocalizations.of(context).time, + readOnly: true, + suffixIcon: Icons.access_time_rounded, + onTap: () async { + final pickedTime = await showTimePicker( + context: context, + initialTime: _selectedTime, + ); + if (pickedTime != null) { + setState(() { + _selectedTime = pickedTime; + _timeController.text = pickedTime.format(context); + }); + } + }, + ), + ), + ], + ), + const SizedBox(height: 16), + // Weight field with +/- buttons + _buildWeightField(numberFormat), + const SizedBox(height: 24), + // Save button + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _saveWeight, + style: ElevatedButton.styleFrom( + backgroundColor: wgerAccentColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + AppLocalizations.of(context).save, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + bool readOnly = false, + TextInputType? keyboardType, + IconData? suffixIcon, + VoidCallback? onTap, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + readOnly: readOnly, + keyboardType: keyboardType, + onTap: onTap, + validator: validator, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(fontSize: 13), + filled: true, + fillColor: widget.isDarkMode + ? Colors.grey.shade800.withValues(alpha: 0.5) + : Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: wgerAccentColor, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Theme.of(context).colorScheme.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Theme.of(context).colorScheme.error, width: 2), + ), + suffixIcon: suffixIcon != null ? Icon(suffixIcon, color: wgerAccentColor, size: 18) : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ); + } + + Widget _buildWeightField(NumberFormat numberFormat) { + return Container( + decoration: BoxDecoration( + color: widget.isDarkMode + ? Colors.grey.shade800.withValues(alpha: 0.5) + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Minus buttons + _buildLabeledButton( + label: '-1', + onTap: () => _adjustWeight(-1, numberFormat), + isFirst: true, + ), + _buildLabeledButton( + label: '-.1', + onTap: () => _adjustWeight(-0.1, numberFormat), + ), + // Weight input + Expanded( + child: TextFormField( + controller: _weightController, + keyboardType: textInputTypeDecimal, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + decoration: InputDecoration( + hintText: '0.0', + hintStyle: TextStyle( + color: widget.isDarkMode ? Colors.grey.shade600 : Colors.grey.shade400, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 14), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context).enterValue; + } + try { + numberFormat.parse(value); + } catch (error) { + return AppLocalizations.of(context).enterValidNumber; + } + return null; + }, + ), + ), + // Plus buttons + _buildLabeledButton( + label: '+.1', + onTap: () => _adjustWeight(0.1, numberFormat), + ), + _buildLabeledButton( + label: '+1', + onTap: () => _adjustWeight(1, numberFormat), + isLast: true, + ), + ], + ), + ); + } + + Widget _buildLabeledButton({ + required String label, + required VoidCallback onTap, + bool isFirst = false, + bool isLast = false, + }) { + return Padding( + padding: EdgeInsets.only( + left: isFirst ? 8 : 4, + right: isLast ? 8 : 4, + top: 8, + bottom: 8, + ), + child: Material( + color: wgerAccentColor.withValues(alpha: widget.isDarkMode ? 0.2 : 0.12), + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: wgerAccentColor, + ), + ), + ), + ), + ), + ); + } + + void _adjustWeight(double delta, NumberFormat numberFormat) { + try { + final currentValue = _weightController.text.isNotEmpty + ? numberFormat.parse(_weightController.text) + : 0.0; + final newValue = currentValue + delta; + if (newValue >= 0) { + _weightController.text = numberFormat.format(newValue); + } + } on FormatException { + // Ignore format errors + } + } + + Future _saveWeight() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + final weight = numberFormat.parse(_weightController.text); + final provider = Provider.of(context, listen: false); + + // Combine date and time + final dateTime = DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day, + _selectedTime.hour, + _selectedTime.minute, + ); + + try { + final entry = WeightEntry( + id: widget.entry?.id, + date: dateTime, + weight: weight, + ); + + if (widget.isNewEntry) { + await provider.addEntry(entry); + } else { + await provider.editEntry(entry); + } + + if (mounted) { + Navigator.pop(context); + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).anErrorOccurred, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} diff --git a/lib/widgets/weight/entries_modal.dart b/lib/widgets/weight/entries_modal.dart new file mode 100644 index 000000000..c20a325a1 --- /dev/null +++ b/lib/widgets/weight/entries_modal.dart @@ -0,0 +1,368 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/body_weight/weight_entry.dart'; +import 'package:wger/providers/body_weight.dart'; +import 'package:wger/providers/user.dart'; +import 'package:wger/theme/theme.dart'; +import 'package:wger/widgets/measurements/charts.dart'; + +import 'edit_modal.dart'; + +/// Shows a modal bottom sheet with all weight entries +void showWeightEntriesModal(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) => Container( + decoration: BoxDecoration( + color: isDarkMode ? Theme.of(context).scaffoldBackgroundColor : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Handle bar + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey.shade600 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 16), + child: _WeightEntriesHeader(isDarkMode: isDarkMode), + ), + // Entries list + Expanded( + child: Consumer( + builder: (context, provider, child) { + final entries = provider.items; + if (entries.isEmpty) { + return _buildEmptyState(context, isDarkMode); + } + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + itemCount: entries.length, + itemBuilder: (context, index) => _WeightEntryTile( + entry: entries[index], + isDarkMode: isDarkMode, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); +} + +Widget _buildEmptyState(BuildContext context, bool isDarkMode) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.monitor_weight_outlined, + size: 64, + color: isDarkMode ? Colors.grey.shade600 : Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context).noWeightEntries, + style: TextStyle( + fontSize: 16, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => showEditWeightModal(context, null), + icon: const Icon(Icons.add, size: 18), + label: Text(AppLocalizations.of(context).newEntry), + style: ElevatedButton.styleFrom( + backgroundColor: wgerAccentColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ); +} + +class _WeightEntriesHeader extends StatelessWidget { + final bool isDarkMode; + + const _WeightEntriesHeader({required this.isDarkMode}); + + @override + Widget build(BuildContext context) { + final provider = Provider.of(context); + final entryCount = provider.items.length; + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).weight, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '$entryCount ${AppLocalizations.of(context).entries}', + style: TextStyle( + fontSize: 14, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _WeightEntryTile extends StatelessWidget { + final WeightEntry entry; + final bool isDarkMode; + + const _WeightEntryTile({ + required this.entry, + required this.isDarkMode, + }); + + @override + Widget build(BuildContext context) { + final profile = context.read().profile; + final numberFormat = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final dateFormat = DateFormat.yMMMd( + Localizations.localeOf(context).languageCode, + ); + final timeFormat = DateFormat.Hm( + Localizations.localeOf(context).languageCode, + ); + + final unit = weightUnit(profile!.isMetric, context); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey.shade800.withValues(alpha: 0.5) : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Weight value + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + numberFormat.format(entry.weight), + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), + Text( + unit, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${dateFormat.format(entry.date)} ${timeFormat.format(entry.date)}', + style: TextStyle( + fontSize: 13, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + ], + ), + ), + // Actions + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildActionButton( + context, + icon: Icons.edit_outlined, + onTap: () => showEditWeightModal(context, entry), + ), + const SizedBox(width: 8), + _buildActionButton( + context, + icon: Icons.delete_outline, + onTap: () => _showDeleteEntryDialog(context, entry), + isDelete: true, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildActionButton( + BuildContext context, { + required IconData icon, + required VoidCallback onTap, + bool isDelete = false, + }) { + return Material( + color: isDelete + ? Theme.of(context).colorScheme.error.withValues(alpha: 0.1) + : wgerAccentColor.withValues(alpha: isDarkMode ? 0.2 : 0.12), + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon( + icon, + size: 18, + color: isDelete ? Theme.of(context).colorScheme.error : wgerAccentColor, + ), + ), + ), + ); + } + + void _showDeleteEntryDialog(BuildContext context, WeightEntry entry) { + final numberFormat = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final dateFormat = DateFormat.yMMMd( + Localizations.localeOf(context).languageCode, + ); + final profile = context.read().profile; + final unit = weightUnit(profile!.isMetric, context); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context).delete), + content: Text( + '${numberFormat.format(entry.weight)} $unit - ${dateFormat.format(entry.date)}', + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () => Navigator.pop(dialogContext), + child: Text( + MaterialLocalizations.of(context).cancelButtonLabel, + style: TextStyle(color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600), + ), + ), + TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error.withValues(alpha: 0.1), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () async { + try { + await Provider.of( + context, + listen: false, + ).deleteEntry(entry.id!); + if (context.mounted) { + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).successfullyDeleted, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + } catch (error) { + if (context.mounted) { + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).anErrorOccurred, + textAlign: TextAlign.center, + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + child: Text( + AppLocalizations.of(context).delete, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/weight/forms.dart b/lib/widgets/weight/forms.dart deleted file mode 100644 index c3e182554..000000000 --- a/lib/widgets/weight/forms.dart +++ /dev/null @@ -1,226 +0,0 @@ -/* - * 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 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/helpers/consts.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/body_weight/weight_entry.dart'; -import 'package:wger/providers/body_weight.dart'; - -class WeightForm extends StatelessWidget { - final _form = GlobalKey(); - final dateController = TextEditingController(text: ''); - final timeController = TextEditingController(text: ''); - final weightController = TextEditingController(text: ''); - - final WeightEntry _weightEntry; - - WeightForm([WeightEntry? weightEntry]) - : _weightEntry = weightEntry ?? WeightEntry(date: DateTime.now()); - - @override - Widget build(BuildContext context) { - final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); - final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode); - final timeFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode); - - if (weightController.text.isEmpty && _weightEntry.weight != 0) { - weightController.text = numberFormat.format(_weightEntry.weight); - } - if (dateController.text.isEmpty) { - dateController.text = dateFormat.format(_weightEntry.date); - } - if (timeController.text.isEmpty) { - timeController.text = TimeOfDay.fromDateTime(_weightEntry.date).format(context); - } - - return Form( - key: _form, - child: Column( - children: [ - TextFormField( - key: const Key('dateInput'), - // Stop keyboard from appearing - readOnly: true, - decoration: InputDecoration( - labelText: AppLocalizations.of(context).date, - suffixIcon: const Icon( - Icons.calendar_today, - key: Key('calendarIcon'), - ), - ), - enableInteractiveSelection: false, - controller: dateController, - onTap: () async { - final pickedDate = await showDatePicker( - context: context, - initialDate: _weightEntry.date, - firstDate: DateTime(DateTime.now().year - 10), - lastDate: DateTime.now(), - ); - - if (pickedDate != null) { - dateController.text = dateFormat.format(pickedDate); - } - }, - onSaved: (newValue) { - final date = dateFormat.parse(newValue!); - _weightEntry.date = _weightEntry.date.copyWith( - year: date.year, - month: date.month, - day: date.day, - ); - }, - ), - TextFormField( - key: const Key('timeInput'), - // Stop keyboard from appearing - readOnly: true, - decoration: InputDecoration( - labelText: AppLocalizations.of(context).time, - suffixIcon: const Icon( - Icons.access_time_outlined, - key: Key('clockIcon'), - ), - ), - enableInteractiveSelection: false, - controller: timeController, - onTap: () async { - final pickedTime = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(_weightEntry.date), - ); - - if (pickedTime != null) { - timeController.text = pickedTime.format(context); - } - }, - onSaved: (newValue) { - final time = timeFormat.parse(newValue!); - _weightEntry.date = _weightEntry.date.copyWith( - hour: time.hour, - minute: time.minute, - second: time.second, - ); - }, - ), - - // Weight - TextFormField( - key: const Key('weightInput'), - decoration: InputDecoration( - labelText: AppLocalizations.of(context).weight, - prefix: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - key: const Key('quickMinus'), - icon: const FaIcon(FontAwesomeIcons.circleMinus), - onPressed: () { - try { - final newValue = numberFormat.parse(weightController.text) - 1; - weightController.text = numberFormat.format(newValue); - } on FormatException {} - }, - ), - IconButton( - key: const Key('quickMinusSmall'), - icon: const FaIcon(FontAwesomeIcons.minus), - onPressed: () { - try { - final newValue = numberFormat.parse(weightController.text) - 0.1; - weightController.text = numberFormat.format(newValue); - } on FormatException {} - }, - ), - ], - ), - suffix: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - key: const Key('quickPlusSmall'), - icon: const FaIcon(FontAwesomeIcons.plus), - onPressed: () { - try { - final newValue = numberFormat.parse(weightController.text) + 0.1; - weightController.text = numberFormat.format(newValue); - } on FormatException {} - }, - ), - IconButton( - key: const Key('quickPlus'), - icon: const FaIcon(FontAwesomeIcons.circlePlus), - onPressed: () { - try { - final newValue = numberFormat.parse(weightController.text) + 1; - weightController.text = numberFormat.format(newValue); - } on FormatException {} - }, - ), - ], - ), - ), - controller: weightController, - keyboardType: textInputTypeDecimal, - onSaved: (newValue) { - _weightEntry.weight = numberFormat.parse(newValue!); - }, - validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; - } - - try { - numberFormat.parse(value); - } catch (error) { - return AppLocalizations.of(context).enterValidNumber; - } - return null; - }, - ), - ElevatedButton( - key: const Key(SUBMIT_BUTTON_KEY_NAME), - child: Text(AppLocalizations.of(context).save), - onPressed: () async { - // Validate and save the current values to the weightEntry - final isValid = _form.currentState!.validate(); - if (!isValid) { - return; - } - _form.currentState!.save(); - - // Save the entry on the server - final provider = Provider.of(context, listen: false); - _weightEntry.id == null - ? await provider.addEntry(_weightEntry) - : await provider.editEntry(_weightEntry); - - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - ], - ), - ); - } -} diff --git a/lib/widgets/weight/weight_overview.dart b/lib/widgets/weight/weight_overview.dart deleted file mode 100644 index f6f9050ea..000000000 --- a/lib/widgets/weight/weight_overview.dart +++ /dev/null @@ -1,135 +0,0 @@ -/* - * 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 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/providers/body_weight.dart'; -import 'package:wger/providers/nutrition.dart'; -import 'package:wger/providers/user.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/screens/measurement_categories_screen.dart'; -import 'package:wger/widgets/measurements/charts.dart'; -import 'package:wger/widgets/measurements/helpers.dart'; -import 'package:wger/widgets/weight/forms.dart'; - -class WeightOverview extends StatelessWidget { - final BodyWeightProvider _provider; - - const WeightOverview(this._provider); - - @override - Widget build(BuildContext context) { - final profile = context.read().profile; - final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); - final plans = Provider.of(context, listen: false).items; - - final entriesAll = _provider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(); - final entries7dAvg = moving7dAverage(entriesAll); - - final unit = weightUnit(profile!.isMetric, context); - - return Column( - children: [ - ...getOverviewWidgetsSeries( - AppLocalizations.of(context).weight, - entriesAll, - entries7dAvg, - plans, - unit, - context, - ), - TextButton( - onPressed: () => Navigator.pushNamed( - context, - MeasurementCategoriesScreen.routeName, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text(AppLocalizations.of(context).measurements), - const Icon(Icons.chevron_right), - ], - ), - ), - SizedBox( - height: 300, - child: RefreshIndicator( - onRefresh: () => _provider.fetchAndSetEntries(), - child: ListView.builder( - padding: const EdgeInsets.all(10.0), - itemCount: _provider.items.length, - itemBuilder: (context, index) { - final currentEntry = _provider.items[index]; - return Card( - child: ListTile( - title: Text( - '${numberFormat.format(currentEntry.weight)} ${weightUnit(profile.isMetric, context)}', - ), - subtitle: Text( - DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).add_Hm().format(currentEntry.date), - ), - trailing: PopupMenuButton( - itemBuilder: (BuildContext context) { - return [ - PopupMenuItem( - child: Text(AppLocalizations.of(context).edit), - onTap: () => Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).edit, - WeightForm(currentEntry), - ), - ), - ), - PopupMenuItem( - child: Text(AppLocalizations.of(context).delete), - onTap: () async { - // Delete entry from DB - await _provider.deleteEntry(currentEntry.id!); - - // and inform the user - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).successfullyDeleted, - textAlign: TextAlign.center, - ), - ), - ); - } - }, - ), - ]; - }, - ), - ), - ); - }, - ), - ), - ), - ], - ); - } -} diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 8791d455c..405d8288c 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -1773,6 +1773,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get entries => + (super.noSuchMethod( + Invocation.getter(#entries), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#entries), + ), + ) + as String); + @override String get date => (super.noSuchMethod( @@ -3806,6 +3817,66 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get week => + (super.noSuchMethod( + Invocation.getter(#week), + returnValue: _i3.dummyValue(this, Invocation.getter(#week)), + ) + as String); + + @override + String get month => + (super.noSuchMethod( + Invocation.getter(#month), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#month), + ), + ) + as String); + + @override + String get sixMonths => + (super.noSuchMethod( + Invocation.getter(#sixMonths), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#sixMonths), + ), + ) + as String); + + @override + String get year => + (super.noSuchMethod( + Invocation.getter(#year), + returnValue: _i3.dummyValue(this, Invocation.getter(#year)), + ) + as String); + + @override + String get recentEntries => + (super.noSuchMethod( + Invocation.getter(#recentEntries), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#recentEntries), + ), + ) + as String); + + @override + String get seeAll => + (super.noSuchMethod( + Invocation.getter(#seeAll), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#seeAll), + ), + ) + as String); + @override String exerciseNr(String? nr) => (super.noSuchMethod( diff --git a/test/measurements/edit_modals_test.dart b/test/measurements/edit_modals_test.dart new file mode 100644 index 000000000..c29a3b977 --- /dev/null +++ b/test/measurements/edit_modals_test.dart @@ -0,0 +1,456 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/measurements/measurement_category.dart'; +import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/providers/measurement.dart'; +import 'package:wger/widgets/measurements/edit_modals.dart'; + +import 'edit_modals_test.mocks.dart'; + +@GenerateMocks([MeasurementProvider]) +void main() { + late MockMeasurementProvider mockProvider; + late MeasurementCategory testCategory; + late MeasurementEntry testEntry; + + setUp(() { + mockProvider = MockMeasurementProvider(); + testCategory = MeasurementCategory( + id: 1, + name: 'Body Fat', + unit: '%', + entries: [], + ); + testEntry = MeasurementEntry( + id: 1, + category: 1, + date: DateTime(2021, 9, 1), + value: 15.5, + notes: 'Morning measurement', + ); + }); + + group('Edit Entry Modal', () { + Widget createEditEntryModalTestWidget({ + MeasurementEntry? entry, + String locale = 'en', + }) { + return ChangeNotifierProvider.value( + value: mockProvider, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showEditEntryModal(context, testCategory, entry), + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + } + + testWidgets('Shows new entry modal with empty fields', (WidgetTester tester) async { + await tester.pumpWidget(createEditEntryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows "New Entry" title + expect(find.text('New entry'), findsOneWidget); + + // Verify date field is populated with today's date + expect(find.byType(TextFormField), findsNWidgets(3)); // date, value, notes + + // Verify save button is present + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('Shows edit entry modal with prefilled data', (WidgetTester tester) async { + await tester.pumpWidget(createEditEntryModalTestWidget(entry: testEntry)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows "Edit" title + expect(find.text('Edit'), findsOneWidget); + + // Verify value is prefilled + expect(find.text('15.5'), findsOneWidget); + + // Verify notes is prefilled + expect(find.text('Morning measurement'), findsOneWidget); + }); + + testWidgets('Validates empty value field', (WidgetTester tester) async { + await tester.pumpWidget(createEditEntryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Try to save without entering value + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify validation error is shown + expect(find.text('Please enter a value'), findsOneWidget); + }); + + testWidgets('Validates invalid number input', (WidgetTester tester) async { + await tester.pumpWidget(createEditEntryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter invalid number + final valueField = find.byType(TextFormField).at(1); // value field + await tester.enterText(valueField, 'not a number'); + await tester.pumpAndSettle(); + + // Try to save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify validation error is shown + expect(find.text('Please enter a valid number'), findsOneWidget); + }); + + testWidgets('Saves new entry successfully', (WidgetTester tester) async { + when(mockProvider.addEntry(any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createEditEntryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter value + final valueField = find.byType(TextFormField).at(1); + await tester.enterText(valueField, '20.5'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.addEntry(any)).called(1); + + // Modal should be closed + expect(find.text('New entry'), findsNothing); + }); + + testWidgets('Updates existing entry successfully', (WidgetTester tester) async { + when(mockProvider.editEntry(any, any, any, any, any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createEditEntryModalTestWidget(entry: testEntry)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Modify value + final valueField = find.byType(TextFormField).at(1); + await tester.enterText(valueField, '16.0'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.editEntry(any, any, any, any, any)).called(1); + }); + + testWidgets('Shows error snackbar when save fails', (WidgetTester tester) async { + when(mockProvider.addEntry(any)).thenThrow(Exception('Network error')); + + await tester.pumpWidget(createEditEntryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter value + final valueField = find.byType(TextFormField).at(1); + await tester.enterText(valueField, '20.5'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify error snackbar appears + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('Shows loading indicator while saving', (WidgetTester tester) async { + when(mockProvider.addEntry(any)).thenAnswer( + (_) => Future.delayed(const Duration(seconds: 2)), + ); + + await tester.pumpWidget(createEditEntryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter value + final valueField = find.byType(TextFormField).at(1); + await tester.enterText(valueField, '20.5'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pump(); + + // Verify loading indicator appears + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Wait for save to complete + await tester.pumpAndSettle(); + }); + + testWidgets('Opens date picker when tapping date field', (WidgetTester tester) async { + await tester.pumpWidget(createEditEntryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap date field + final dateField = find.byType(TextFormField).first; + await tester.tap(dateField); + await tester.pumpAndSettle(); + + // Verify date picker appears + expect(find.byType(DatePickerDialog), findsOneWidget); + }); + }); + + group('Edit Category Modal', () { + Widget createEditCategoryModalTestWidget({ + MeasurementCategory? category, + String locale = 'en', + }) { + return ChangeNotifierProvider.value( + value: mockProvider, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showEditCategoryModal(context, category), + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + } + + testWidgets('Shows new category modal with empty fields', (WidgetTester tester) async { + await tester.pumpWidget(createEditCategoryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows "New Entry" title (for new category) + expect(find.text('New entry'), findsOneWidget); + + // Verify fields are present + expect(find.byType(TextFormField), findsNWidgets(2)); // name, unit + + // Verify save button is present + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('Shows edit category modal with prefilled data', (WidgetTester tester) async { + await tester.pumpWidget(createEditCategoryModalTestWidget(category: testCategory)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows "Edit" title + expect(find.text('Edit'), findsOneWidget); + + // Verify name is prefilled + expect(find.text('Body Fat'), findsOneWidget); + + // Verify unit is prefilled + expect(find.text('%'), findsOneWidget); + }); + + testWidgets('Validates empty name field', (WidgetTester tester) async { + await tester.pumpWidget(createEditCategoryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Try to save without entering name + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify validation error is shown + expect(find.text('Please enter a value'), findsWidgets); + }); + + testWidgets('Saves new category successfully', (WidgetTester tester) async { + when(mockProvider.addCategory(any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createEditCategoryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter name + final nameField = find.byType(TextFormField).first; + await tester.enterText(nameField, 'Biceps'); + await tester.pumpAndSettle(); + + // Enter unit + final unitField = find.byType(TextFormField).last; + await tester.enterText(unitField, 'cm'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.addCategory(any)).called(1); + + // Modal should be closed + expect(find.text('New entry'), findsNothing); + }); + + testWidgets('Updates existing category successfully', (WidgetTester tester) async { + when(mockProvider.editCategory(any, any, any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createEditCategoryModalTestWidget(category: testCategory)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Modify name + final nameField = find.byType(TextFormField).first; + await tester.enterText(nameField, 'Body Fat Percentage'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.editCategory(1, 'Body Fat Percentage', '%')).called(1); + }); + + testWidgets('Shows error snackbar when save fails', (WidgetTester tester) async { + when(mockProvider.addCategory(any)).thenThrow(Exception('Network error')); + + await tester.pumpWidget(createEditCategoryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter name and unit + final nameField = find.byType(TextFormField).first; + await tester.enterText(nameField, 'Biceps'); + final unitField = find.byType(TextFormField).last; + await tester.enterText(unitField, 'cm'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify error snackbar appears + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('Shows loading indicator while saving', (WidgetTester tester) async { + when(mockProvider.addCategory(any)).thenAnswer( + (_) => Future.delayed(const Duration(seconds: 2)), + ); + + await tester.pumpWidget(createEditCategoryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter name and unit + final nameField = find.byType(TextFormField).first; + await tester.enterText(nameField, 'Biceps'); + final unitField = find.byType(TextFormField).last; + await tester.enterText(unitField, 'cm'); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pump(); + + // Verify loading indicator appears + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Wait for save to complete + await tester.pumpAndSettle(); + }); + + testWidgets('Modal can be dismissed by dragging down', (WidgetTester tester) async { + await tester.pumpWidget(createEditCategoryModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal is open + expect(find.text('New entry'), findsOneWidget); + + // Find the handle bar and drag down + final handleBar = find.byType(Container).first; + await tester.drag(handleBar, const Offset(0, 500)); + await tester.pumpAndSettle(); + + // Modal should be closed + expect(find.text('New entry'), findsNothing); + }); + }); +} diff --git a/test/measurements/edit_modals_test.mocks.dart b/test/measurements/edit_modals_test.mocks.dart new file mode 100644 index 000000000..defae6ddb --- /dev/null +++ b/test/measurements/edit_modals_test.mocks.dart @@ -0,0 +1,204 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/measurements/edit_modals_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; +import 'dart:ui' as _i7; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:wger/models/measurements/measurement_category.dart' as _i3; +import 'package:wger/models/measurements/measurement_entry.dart' as _i6; +import 'package:wger/providers/base_provider.dart' as _i2; +import 'package:wger/providers/measurement.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeWgerBaseProvider_0 extends _i1.SmartFake implements _i2.WgerBaseProvider { + _FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMeasurementCategory_1 extends _i1.SmartFake implements _i3.MeasurementCategory { + _FakeMeasurementCategory_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [MeasurementProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMeasurementProvider extends _i1.Mock implements _i4.MeasurementProvider { + MockMeasurementProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.WgerBaseProvider get baseProvider => + (super.noSuchMethod( + Invocation.getter(#baseProvider), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#baseProvider), + ), + ) + as _i2.WgerBaseProvider); + + @override + List<_i3.MeasurementCategory> get categories => + (super.noSuchMethod( + Invocation.getter(#categories), + returnValue: <_i3.MeasurementCategory>[], + ) + as List<_i3.MeasurementCategory>); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); + + @override + void clear() => super.noSuchMethod( + Invocation.method(#clear, []), + returnValueForMissingStub: null, + ); + + @override + _i3.MeasurementCategory findCategoryById(int? id) => + (super.noSuchMethod( + Invocation.method(#findCategoryById, [id]), + returnValue: _FakeMeasurementCategory_1( + this, + Invocation.method(#findCategoryById, [id]), + ), + ) + as _i3.MeasurementCategory); + + @override + _i5.Future fetchAndSetCategories() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetCategories, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future fetchAndSetCategoryEntries(int? id) => + (super.noSuchMethod( + Invocation.method(#fetchAndSetCategoryEntries, [id]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future fetchAndSetAllCategoriesAndEntries() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetAllCategoriesAndEntries, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future addCategory(_i3.MeasurementCategory? category) => + (super.noSuchMethod( + Invocation.method(#addCategory, [category]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteCategory(int? id) => + (super.noSuchMethod( + Invocation.method(#deleteCategory, [id]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future editCategory(int? id, String? newName, String? newUnit) => + (super.noSuchMethod( + Invocation.method(#editCategory, [id, newName, newUnit]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future addEntry(_i6.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addEntry, [entry]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteEntry(int? id, int? categoryId) => + (super.noSuchMethod( + Invocation.method(#deleteEntry, [id, categoryId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future editEntry( + int? id, + int? categoryId, + num? newValue, + String? newNotes, + DateTime? newDate, + ) => + (super.noSuchMethod( + Invocation.method(#editEntry, [ + id, + categoryId, + newValue, + newNotes, + newDate, + ]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#addListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#removeListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method(#notifyListeners, []), + returnValueForMissingStub: null, + ); +} diff --git a/test/measurements/entries_modal_test.dart b/test/measurements/entries_modal_test.dart new file mode 100644 index 000000000..8c0086be1 --- /dev/null +++ b/test/measurements/entries_modal_test.dart @@ -0,0 +1,317 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/measurements/measurement_category.dart'; +import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/providers/measurement.dart'; +import 'package:wger/widgets/measurements/entries_modal.dart'; + +import 'entries_modal_test.mocks.dart'; + +@GenerateMocks([MeasurementProvider]) +void main() { + late MockMeasurementProvider mockProvider; + late MeasurementCategory testCategory; + + setUp(() { + mockProvider = MockMeasurementProvider(); + testCategory = MeasurementCategory( + id: 1, + name: 'Body Fat', + unit: '%', + entries: [ + MeasurementEntry( + id: 1, + category: 1, + date: DateTime(2021, 9, 1), + value: 15.5, + notes: 'Morning measurement', + ), + MeasurementEntry( + id: 2, + category: 1, + date: DateTime(2021, 9, 5), + value: 15.2, + notes: '', + ), + MeasurementEntry( + id: 3, + category: 1, + date: DateTime(2021, 9, 10), + value: 14.8, + notes: 'After workout', + ), + ], + ); + + when(mockProvider.findCategoryById(1)).thenReturn(testCategory); + }); + + Widget createEntriesModalTestWidget({String locale = 'en'}) { + return ChangeNotifierProvider.value( + value: mockProvider, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showEntriesModal(context, testCategory), + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + } + + testWidgets('Shows entries modal with category name and entry count', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + // Tap button to open modal + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows category name + expect(find.text('Body Fat'), findsOneWidget); + + // Verify entry count is displayed + expect(find.text('3 entries'), findsOneWidget); + }); + + testWidgets('Shows all entries with formatted values', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify values are displayed (formatted numbers may vary by locale) + expect(find.text('15.5'), findsOneWidget); + expect(find.text('15.2'), findsOneWidget); + expect(find.text('14.8'), findsOneWidget); + + // Verify unit is displayed for each entry + expect(find.text('%'), findsNWidgets(3)); + }); + + testWidgets('Shows edit and delete buttons for each entry', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Each entry should have edit and delete icons + expect(find.byIcon(Icons.edit_outlined), findsNWidgets(4)); // 3 entries + 1 category edit + expect(find.byIcon(Icons.delete_outline), findsNWidgets(4)); // 3 entries + 1 category delete + }); + + testWidgets('Shows empty state when no entries', (WidgetTester tester) async { + final emptyCategory = MeasurementCategory( + id: 2, + name: 'Empty Category', + unit: 'cm', + entries: [], + ); + + when(mockProvider.findCategoryById(2)).thenReturn(emptyCategory); + + final widget = ChangeNotifierProvider.value( + value: mockProvider, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showEntriesModal(context, emptyCategory), + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Should show "0 entries" + expect(find.text('0 entries'), findsOneWidget); + + // Should show empty state icon + expect(find.byIcon(Icons.straighten_outlined), findsOneWidget); + }); + + testWidgets('Delete entry shows confirmation dialog', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap the first delete button (skip the category delete button at index 0) + final deleteButtons = find.byIcon(Icons.delete_outline); + await tester.tap(deleteButtons.at(2)); // Entry delete button + await tester.pumpAndSettle(); + + // Verify confirmation dialog appears + expect(find.text('Delete'), findsNWidgets(2)); // Dialog title and button + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Delete entry calls provider method on confirmation', (WidgetTester tester) async { + when(mockProvider.deleteEntry(any, any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap delete on an entry + final deleteButtons = find.byIcon(Icons.delete_outline); + await tester.tap(deleteButtons.at(2)); + await tester.pumpAndSettle(); + + // Confirm deletion + final confirmDeleteButton = find.widgetWithText(TextButton, 'Delete'); + await tester.tap(confirmDeleteButton.last); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.deleteEntry(any, any)).called(1); + }); + + testWidgets('Delete category shows confirmation dialog', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap the category delete button (first delete button in header) + final deleteButtons = find.byIcon(Icons.delete_outline); + await tester.tap(deleteButtons.first); + await tester.pumpAndSettle(); + + // Verify confirmation dialog shows category name + expect(find.textContaining('Body Fat'), findsWidgets); + }); + + testWidgets('Delete category calls provider method on confirmation', (WidgetTester tester) async { + when(mockProvider.deleteCategory(any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap category delete button + final deleteButtons = find.byIcon(Icons.delete_outline); + await tester.tap(deleteButtons.first); + await tester.pumpAndSettle(); + + // Confirm deletion + final confirmDeleteButton = find.widgetWithText(TextButton, 'Delete'); + await tester.tap(confirmDeleteButton.last); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.deleteCategory(1)).called(1); + }); + + testWidgets('Shows error snackbar when delete entry fails', (WidgetTester tester) async { + when(mockProvider.deleteEntry(any, any)).thenThrow(Exception('Network error')); + + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap delete on an entry + final deleteButtons = find.byIcon(Icons.delete_outline); + await tester.tap(deleteButtons.at(2)); + await tester.pumpAndSettle(); + + // Confirm deletion + final confirmDeleteButton = find.widgetWithText(TextButton, 'Delete'); + await tester.tap(confirmDeleteButton.last); + await tester.pumpAndSettle(); + + // Verify error snackbar appears + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('Shows error snackbar when delete category fails', (WidgetTester tester) async { + when(mockProvider.deleteCategory(any)).thenThrow(Exception('Network error')); + + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap category delete button + final deleteButtons = find.byIcon(Icons.delete_outline); + await tester.tap(deleteButtons.first); + await tester.pumpAndSettle(); + + // Confirm deletion + final confirmDeleteButton = find.widgetWithText(TextButton, 'Delete'); + await tester.tap(confirmDeleteButton.last); + await tester.pumpAndSettle(); + + // Verify error snackbar appears + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('Modal can be dismissed by dragging down', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal is open + expect(find.text('Body Fat'), findsOneWidget); + + // Drag down to dismiss + await tester.drag(find.text('Body Fat'), const Offset(0, 500)); + await tester.pumpAndSettle(); + + // Modal should be closed + expect(find.text('Body Fat'), findsNothing); + }); +} diff --git a/test/measurements/entries_modal_test.mocks.dart b/test/measurements/entries_modal_test.mocks.dart new file mode 100644 index 000000000..366d51795 --- /dev/null +++ b/test/measurements/entries_modal_test.mocks.dart @@ -0,0 +1,204 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/measurements/entries_modal_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; +import 'dart:ui' as _i7; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:wger/models/measurements/measurement_category.dart' as _i3; +import 'package:wger/models/measurements/measurement_entry.dart' as _i6; +import 'package:wger/providers/base_provider.dart' as _i2; +import 'package:wger/providers/measurement.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeWgerBaseProvider_0 extends _i1.SmartFake implements _i2.WgerBaseProvider { + _FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMeasurementCategory_1 extends _i1.SmartFake implements _i3.MeasurementCategory { + _FakeMeasurementCategory_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [MeasurementProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMeasurementProvider extends _i1.Mock implements _i4.MeasurementProvider { + MockMeasurementProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.WgerBaseProvider get baseProvider => + (super.noSuchMethod( + Invocation.getter(#baseProvider), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#baseProvider), + ), + ) + as _i2.WgerBaseProvider); + + @override + List<_i3.MeasurementCategory> get categories => + (super.noSuchMethod( + Invocation.getter(#categories), + returnValue: <_i3.MeasurementCategory>[], + ) + as List<_i3.MeasurementCategory>); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); + + @override + void clear() => super.noSuchMethod( + Invocation.method(#clear, []), + returnValueForMissingStub: null, + ); + + @override + _i3.MeasurementCategory findCategoryById(int? id) => + (super.noSuchMethod( + Invocation.method(#findCategoryById, [id]), + returnValue: _FakeMeasurementCategory_1( + this, + Invocation.method(#findCategoryById, [id]), + ), + ) + as _i3.MeasurementCategory); + + @override + _i5.Future fetchAndSetCategories() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetCategories, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future fetchAndSetCategoryEntries(int? id) => + (super.noSuchMethod( + Invocation.method(#fetchAndSetCategoryEntries, [id]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future fetchAndSetAllCategoriesAndEntries() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetAllCategoriesAndEntries, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future addCategory(_i3.MeasurementCategory? category) => + (super.noSuchMethod( + Invocation.method(#addCategory, [category]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteCategory(int? id) => + (super.noSuchMethod( + Invocation.method(#deleteCategory, [id]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future editCategory(int? id, String? newName, String? newUnit) => + (super.noSuchMethod( + Invocation.method(#editCategory, [id, newName, newUnit]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future addEntry(_i6.MeasurementEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addEntry, [entry]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteEntry(int? id, int? categoryId) => + (super.noSuchMethod( + Invocation.method(#deleteEntry, [id, categoryId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future editEntry( + int? id, + int? categoryId, + num? newValue, + String? newNotes, + DateTime? newDate, + ) => + (super.noSuchMethod( + Invocation.method(#editEntry, [ + id, + categoryId, + newValue, + newNotes, + newDate, + ]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#addListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#removeListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method(#notifyListeners, []), + returnValueForMissingStub: null, + ); +} diff --git a/test/measurements/measurement_categories_screen_test.dart b/test/measurements/measurement_categories_screen_test.dart index a222f5dfb..3b9ce3709 100644 --- a/test/measurements/measurement_categories_screen_test.dart +++ b/test/measurements/measurement_categories_screen_test.dart @@ -26,6 +26,7 @@ import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; import 'package:wger/providers/measurement.dart'; import 'package:wger/screens/measurement_categories_screen.dart'; +import 'package:wger/widgets/measurements/categories_card.dart'; import 'package:wger/widgets/measurements/charts.dart'; import 'measurement_categories_screen_test.mocks.dart'; @@ -77,7 +78,7 @@ void main() { expect(find.text('Measurements'), findsOneWidget); expect(find.text('body fat'), findsOneWidget); expect(find.text('biceps'), findsOneWidget); - expect(find.byType(Card), findsNWidgets(2)); + expect(find.byType(CategoriesCard), findsNWidgets(2)); expect(find.byType(MeasurementChartWidgetFl), findsNWidgets(2)); }); } diff --git a/test/measurements/measurement_entries_screen_test.dart b/test/measurements/measurement_entries_screen_test.dart deleted file mode 100644 index 4a721c51f..000000000 --- a/test/measurements/measurement_entries_screen_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/measurements/measurement_category.dart'; -import 'package:wger/models/measurements/measurement_entry.dart'; -import 'package:wger/providers/measurement.dart'; -import 'package:wger/providers/nutrition.dart'; -import 'package:wger/screens/measurement_entries_screen.dart'; - -import '../nutrition/nutritional_plan_form_test.mocks.dart'; -import 'measurement_categories_screen_test.mocks.dart'; - -void main() { - late MockMeasurementProvider mockMeasurementProvider; - late MockNutritionPlansProvider mockNutritionPlansProvider; - - setUp(() { - mockMeasurementProvider = MockMeasurementProvider(); - when(mockMeasurementProvider.findCategoryById(any)).thenReturn( - MeasurementCategory( - id: 1, - name: 'body fat', - unit: '%', - entries: [ - MeasurementEntry(id: 1, category: 1, date: DateTime(2021, 8, 1), value: 10.2, notes: ''), - MeasurementEntry( - id: 1, - category: 1, - date: DateTime(2021, 8, 10), - value: 18.1, - notes: 'a', - ), - ], - ), - ); - - mockNutritionPlansProvider = MockNutritionPlansProvider(); - when(mockNutritionPlansProvider.currentPlan).thenReturn(null); - when(mockNutritionPlansProvider.items).thenReturn([]); - }); - - Widget createHomeScreen({locale = 'en'}) { - final key = GlobalKey(); - - return ChangeNotifierProvider( - create: (context) => mockNutritionPlansProvider, - child: ChangeNotifierProvider( - create: (context) => mockMeasurementProvider, - child: MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - navigatorKey: key, - home: TextButton( - onPressed: () => key.currentState!.push( - MaterialPageRoute( - settings: const RouteSettings(arguments: 1), - builder: (_) => const MeasurementEntriesScreen(), - ), - ), - child: Container(), - ), - ), - ), - ); - } - - testWidgets('Test the widgets on the measurement entries screen', (WidgetTester tester) async { - await tester.pumpWidget(createHomeScreen()); - await tester.tap(find.byType(TextButton)); - await tester.pumpAndSettle(); - - // Nav bar - expect(find.text('body fat'), findsOneWidget); - - // Entries - expect(find.text('15 %'), findsNWidgets(1)); - }); - - testWidgets('Tests the localization of dates - EN', (WidgetTester tester) async { - await tester.pumpWidget(createHomeScreen()); - await tester.tap(find.byType(TextButton)); - await tester.pumpAndSettle(); - - // From the entries list and from the chart - expect(find.text('8/1/2021'), findsWidgets); - expect(find.text('8/10/2021'), findsWidgets); - }); - - testWidgets('Tests the localization of dates - DE', (WidgetTester tester) async { - await tester.pumpWidget(createHomeScreen(locale: 'de')); - await tester.tap(find.byType(TextButton)); - await tester.pumpAndSettle(); - expect(find.text('1.8.2021'), findsWidgets); - expect(find.text('10.8.2021'), findsWidgets); - }); -} diff --git a/test/weight/weight_edit_modal_test.dart b/test/weight/weight_edit_modal_test.dart new file mode 100644 index 000000000..203594c58 --- /dev/null +++ b/test/weight/weight_edit_modal_test.dart @@ -0,0 +1,244 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/body_weight/weight_entry.dart'; +import 'package:wger/providers/body_weight.dart'; +import 'package:wger/widgets/weight/edit_modal.dart'; + +import 'weight_edit_modal_test.mocks.dart'; + +@GenerateMocks([BodyWeightProvider]) +void main() { + late MockBodyWeightProvider mockProvider; + + setUp(() { + mockProvider = MockBodyWeightProvider(); + }); + + Widget createEditWeightModalTestWidget({ + WeightEntry? entry, + String locale = 'en', + }) { + return ChangeNotifierProvider.value( + value: mockProvider, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showEditWeightModal(context, entry), + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + } + + testWidgets('Shows new entry modal with empty fields', (WidgetTester tester) async { + await tester.pumpWidget(createEditWeightModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows "New Entry" title + expect(find.text('New entry'), findsOneWidget); + + // Verify save button is present + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('Shows edit modal with prefilled data', (WidgetTester tester) async { + final testEntry = WeightEntry( + id: 1, + date: DateTime(2021, 1, 1, 15, 30), + weight: 80.0, + ); + + await tester.pumpWidget(createEditWeightModalTestWidget(entry: testEntry)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows "Edit" title + expect(find.text('Edit'), findsOneWidget); + + // Verify weight is prefilled + expect(find.text('80'), findsOneWidget); + }); + + testWidgets('Validates empty weight field', (WidgetTester tester) async { + await tester.pumpWidget(createEditWeightModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Try to save without entering weight + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify validation error is shown + expect(find.text('Please enter a value'), findsOneWidget); + }); + + testWidgets('Saves new entry successfully', (WidgetTester tester) async { + when(mockProvider.addEntry(any)).thenAnswer( + (_) async => WeightEntry( + id: 1, + date: DateTime.now(), + weight: 2.0, + ), + ); + + await tester.pumpWidget(createEditWeightModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter weight value using the +1 button multiple times + await tester.tap(find.text('+1')); + await tester.pump(); + await tester.tap(find.text('+1')); + await tester.pump(); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.addEntry(any)).called(1); + }); + + testWidgets('Updates existing entry successfully', (WidgetTester tester) async { + when(mockProvider.editEntry(any)).thenAnswer((_) async {}); + + final testEntry = WeightEntry( + id: 1, + date: DateTime(2021, 1, 1, 15, 30), + weight: 80.0, + ); + + await tester.pumpWidget(createEditWeightModalTestWidget(entry: testEntry)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Modify weight using +1 button + await tester.tap(find.text('+1')); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockProvider.editEntry(any)).called(1); + }); + + testWidgets('Shows error snackbar when save fails', (WidgetTester tester) async { + when(mockProvider.addEntry(any)).thenThrow(Exception('Network error')); + + await tester.pumpWidget(createEditWeightModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter weight + await tester.tap(find.text('+1')); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify error snackbar appears + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('Weight quick-change buttons work correctly', (WidgetTester tester) async { + await tester.pumpWidget(createEditWeightModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Test +1 button + await tester.tap(find.text('+1')); + await tester.pump(); + expect(find.text('1'), findsOneWidget); + + // Test +.1 button + await tester.tap(find.text('+.1')); + await tester.pump(); + expect(find.text('1.1'), findsOneWidget); + + // Test -1 button (should go to 0.1) + await tester.tap(find.text('-1')); + await tester.pump(); + expect(find.text('0.1'), findsOneWidget); + + // Test -.1 button (should go to 0) + await tester.tap(find.text('-.1')); + await tester.pump(); + expect(find.text('0'), findsOneWidget); + }); + + testWidgets('Shows loading indicator while saving', (WidgetTester tester) async { + when(mockProvider.addEntry(any)).thenAnswer( + (_) => Future.delayed( + const Duration(seconds: 2), + () => WeightEntry(id: 1, date: DateTime.now(), weight: 1.0), + ), + ); + + await tester.pumpWidget(createEditWeightModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Enter weight + await tester.tap(find.text('+1')); + await tester.pumpAndSettle(); + + // Save + await tester.tap(find.text('Save')); + await tester.pump(); + + // Verify loading indicator appears + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Wait for save to complete + await tester.pumpAndSettle(); + }); +} diff --git a/test/weight/weight_edit_modal_test.mocks.dart b/test/weight/weight_edit_modal_test.mocks.dart new file mode 100644 index 000000000..96126dbc8 --- /dev/null +++ b/test/weight/weight_edit_modal_test.mocks.dart @@ -0,0 +1,157 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/weight/weight_edit_modal_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; +import 'dart:ui' as _i6; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:wger/models/body_weight/weight_entry.dart' as _i3; +import 'package:wger/providers/base_provider.dart' as _i2; +import 'package:wger/providers/body_weight.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeWgerBaseProvider_0 extends _i1.SmartFake implements _i2.WgerBaseProvider { + _FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeWeightEntry_1 extends _i1.SmartFake implements _i3.WeightEntry { + _FakeWeightEntry_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +/// A class which mocks [BodyWeightProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockBodyWeightProvider extends _i1.Mock implements _i4.BodyWeightProvider { + MockBodyWeightProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.WgerBaseProvider get baseProvider => + (super.noSuchMethod( + Invocation.getter(#baseProvider), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#baseProvider), + ), + ) + as _i2.WgerBaseProvider); + + @override + List<_i3.WeightEntry> get items => + (super.noSuchMethod( + Invocation.getter(#items), + returnValue: <_i3.WeightEntry>[], + ) + as List<_i3.WeightEntry>); + + @override + set items(List<_i3.WeightEntry>? entries) => super.noSuchMethod( + Invocation.setter(#items, entries), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); + + @override + void clear() => super.noSuchMethod( + Invocation.method(#clear, []), + returnValueForMissingStub: null, + ); + + @override + _i3.WeightEntry findById(int? id) => + (super.noSuchMethod( + Invocation.method(#findById, [id]), + returnValue: _FakeWeightEntry_1( + this, + Invocation.method(#findById, [id]), + ), + ) + as _i3.WeightEntry); + + @override + _i3.WeightEntry? findByDate(DateTime? date) => + (super.noSuchMethod(Invocation.method(#findByDate, [date])) as _i3.WeightEntry?); + + @override + _i5.Future> fetchAndSetEntries() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetEntries, []), + returnValue: _i5.Future>.value( + <_i3.WeightEntry>[], + ), + ) + as _i5.Future>); + + @override + _i5.Future<_i3.WeightEntry> addEntry(_i3.WeightEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addEntry, [entry]), + returnValue: _i5.Future<_i3.WeightEntry>.value( + _FakeWeightEntry_1(this, Invocation.method(#addEntry, [entry])), + ), + ) + as _i5.Future<_i3.WeightEntry>); + + @override + _i5.Future editEntry(_i3.WeightEntry? entry) => + (super.noSuchMethod( + Invocation.method(#editEntry, [entry]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteEntry(int? id) => + (super.noSuchMethod( + Invocation.method(#deleteEntry, [id]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + void addListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#addListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#removeListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method(#notifyListeners, []), + returnValueForMissingStub: null, + ); +} diff --git a/test/weight/weight_entries_modal_test.dart b/test/weight/weight_entries_modal_test.dart new file mode 100644 index 000000000..d3c4a2baf --- /dev/null +++ b/test/weight/weight_entries_modal_test.dart @@ -0,0 +1,234 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/body_weight/weight_entry.dart'; +import 'package:wger/models/user/profile.dart'; +import 'package:wger/providers/body_weight.dart'; +import 'package:wger/providers/user.dart'; +import 'package:wger/widgets/weight/entries_modal.dart'; + +import 'weight_entries_modal_test.mocks.dart'; + +@GenerateMocks([BodyWeightProvider, UserProvider]) +void main() { + late MockBodyWeightProvider mockWeightProvider; + late MockUserProvider mockUserProvider; + late List testEntries; + + setUp(() { + mockWeightProvider = MockBodyWeightProvider(); + mockUserProvider = MockUserProvider(); + + testEntries = [ + WeightEntry( + id: 1, + date: DateTime(2021, 9, 1, 10, 30), + weight: 80.5, + ), + WeightEntry( + id: 2, + date: DateTime(2021, 9, 5, 14, 0), + weight: 80.0, + ), + WeightEntry( + id: 3, + date: DateTime(2021, 9, 10, 8, 15), + weight: 79.5, + ), + ]; + + when(mockWeightProvider.items).thenReturn(testEntries); + when(mockUserProvider.profile).thenReturn( + Profile( + username: 'test', + email: 'test@example.com', + emailVerified: true, + isTrustworthy: false, + weightUnitStr: 'kg', + ), + ); + }); + + Widget createEntriesModalTestWidget({String locale = 'en'}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: mockWeightProvider), + ChangeNotifierProvider.value(value: mockUserProvider), + ], + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showWeightEntriesModal(context), + child: const Text('Open Modal'), + ), + ), + ), + ), + ); + } + + testWidgets('Shows entries modal with weight entries', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + // Tap button to open modal + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal shows weight title + expect(find.text('Weight'), findsOneWidget); + + // Verify entry count is displayed + expect(find.text('3 entries'), findsOneWidget); + }); + + testWidgets('Shows weight values with unit', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify weight values are displayed (formatted numbers may vary by locale) + expect(find.text('80.5'), findsOneWidget); + expect(find.text('80'), findsOneWidget); + expect(find.text('79.5'), findsOneWidget); + + // Verify unit is displayed for each entry (kg for metric) + expect(find.text('kg'), findsNWidgets(3)); + }); + + testWidgets('Shows edit and delete buttons for each entry', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Each entry should have edit and delete icons + expect(find.byIcon(Icons.edit_outlined), findsNWidgets(3)); + expect(find.byIcon(Icons.delete_outline), findsNWidgets(3)); + }); + + testWidgets('Shows empty state when no entries', (WidgetTester tester) async { + when(mockWeightProvider.items).thenReturn([]); + + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Should show "0 entries" + expect(find.text('0 entries'), findsOneWidget); + + // Should show empty state message + expect(find.text('You have no weight entries'), findsOneWidget); + + // Should show empty state icon + expect(find.byIcon(Icons.monitor_weight_outlined), findsOneWidget); + }); + + testWidgets('Delete entry shows confirmation dialog', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap the first delete button + await tester.tap(find.byIcon(Icons.delete_outline).first); + await tester.pumpAndSettle(); + + // Verify confirmation dialog appears + expect(find.text('Delete'), findsNWidgets(2)); // Dialog title and button + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Delete entry calls provider method on confirmation', (WidgetTester tester) async { + when(mockWeightProvider.deleteEntry(any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap delete on first entry + await tester.tap(find.byIcon(Icons.delete_outline).first); + await tester.pumpAndSettle(); + + // Confirm deletion + final confirmDeleteButton = find.widgetWithText(TextButton, 'Delete'); + await tester.tap(confirmDeleteButton.last); + await tester.pumpAndSettle(); + + // Verify provider method was called + verify(mockWeightProvider.deleteEntry(1)).called(1); + }); + + testWidgets('Shows error snackbar when delete fails', (WidgetTester tester) async { + when(mockWeightProvider.deleteEntry(any)).thenThrow(Exception('Network error')); + + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Tap delete on first entry + await tester.tap(find.byIcon(Icons.delete_outline).first); + await tester.pumpAndSettle(); + + // Confirm deletion + final confirmDeleteButton = find.widgetWithText(TextButton, 'Delete'); + await tester.tap(confirmDeleteButton.last); + await tester.pumpAndSettle(); + + // Verify error snackbar appears + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('Modal can be dismissed by dragging down', (WidgetTester tester) async { + await tester.pumpWidget(createEntriesModalTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + // Verify modal is open + expect(find.text('Weight'), findsOneWidget); + + // Drag down to dismiss + await tester.drag(find.text('Weight'), const Offset(0, 500)); + await tester.pumpAndSettle(); + + // Modal should be closed + expect(find.text('3 entries'), findsNothing); + }); +} diff --git a/test/weight/weight_entries_modal_test.mocks.dart b/test/weight/weight_entries_modal_test.mocks.dart new file mode 100644 index 000000000..dc53749c5 --- /dev/null +++ b/test/weight/weight_entries_modal_test.mocks.dart @@ -0,0 +1,290 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/weight/weight_entries_modal_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; +import 'dart:ui' as _i7; + +import 'package:flutter/material.dart' as _i9; +import 'package:mockito/mockito.dart' as _i1; +import 'package:shared_preferences/shared_preferences.dart' as _i4; +import 'package:wger/models/body_weight/weight_entry.dart' as _i3; +import 'package:wger/models/user/profile.dart' as _i10; +import 'package:wger/providers/base_provider.dart' as _i2; +import 'package:wger/providers/body_weight.dart' as _i5; +import 'package:wger/providers/user.dart' as _i8; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeWgerBaseProvider_0 extends _i1.SmartFake implements _i2.WgerBaseProvider { + _FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeWeightEntry_1 extends _i1.SmartFake implements _i3.WeightEntry { + _FakeWeightEntry_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeSharedPreferencesAsync_2 extends _i1.SmartFake implements _i4.SharedPreferencesAsync { + _FakeSharedPreferencesAsync_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [BodyWeightProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockBodyWeightProvider extends _i1.Mock implements _i5.BodyWeightProvider { + MockBodyWeightProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.WgerBaseProvider get baseProvider => + (super.noSuchMethod( + Invocation.getter(#baseProvider), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#baseProvider), + ), + ) + as _i2.WgerBaseProvider); + + @override + List<_i3.WeightEntry> get items => + (super.noSuchMethod( + Invocation.getter(#items), + returnValue: <_i3.WeightEntry>[], + ) + as List<_i3.WeightEntry>); + + @override + set items(List<_i3.WeightEntry>? entries) => super.noSuchMethod( + Invocation.setter(#items, entries), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); + + @override + void clear() => super.noSuchMethod( + Invocation.method(#clear, []), + returnValueForMissingStub: null, + ); + + @override + _i3.WeightEntry findById(int? id) => + (super.noSuchMethod( + Invocation.method(#findById, [id]), + returnValue: _FakeWeightEntry_1( + this, + Invocation.method(#findById, [id]), + ), + ) + as _i3.WeightEntry); + + @override + _i3.WeightEntry? findByDate(DateTime? date) => + (super.noSuchMethod(Invocation.method(#findByDate, [date])) as _i3.WeightEntry?); + + @override + _i6.Future> fetchAndSetEntries() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetEntries, []), + returnValue: _i6.Future>.value( + <_i3.WeightEntry>[], + ), + ) + as _i6.Future>); + + @override + _i6.Future<_i3.WeightEntry> addEntry(_i3.WeightEntry? entry) => + (super.noSuchMethod( + Invocation.method(#addEntry, [entry]), + returnValue: _i6.Future<_i3.WeightEntry>.value( + _FakeWeightEntry_1(this, Invocation.method(#addEntry, [entry])), + ), + ) + as _i6.Future<_i3.WeightEntry>); + + @override + _i6.Future editEntry(_i3.WeightEntry? entry) => + (super.noSuchMethod( + Invocation.method(#editEntry, [entry]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + _i6.Future deleteEntry(int? id) => + (super.noSuchMethod( + Invocation.method(#deleteEntry, [id]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#addListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#removeListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method(#notifyListeners, []), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [UserProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUserProvider extends _i1.Mock implements _i8.UserProvider { + MockUserProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i9.ThemeMode get themeMode => + (super.noSuchMethod( + Invocation.getter(#themeMode), + returnValue: _i9.ThemeMode.system, + ) + as _i9.ThemeMode); + + @override + _i2.WgerBaseProvider get baseProvider => + (super.noSuchMethod( + Invocation.getter(#baseProvider), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#baseProvider), + ), + ) + as _i2.WgerBaseProvider); + + @override + _i4.SharedPreferencesAsync get prefs => + (super.noSuchMethod( + Invocation.getter(#prefs), + returnValue: _FakeSharedPreferencesAsync_2( + this, + Invocation.getter(#prefs), + ), + ) + as _i4.SharedPreferencesAsync); + + @override + set themeMode(_i9.ThemeMode? value) => super.noSuchMethod( + Invocation.setter(#themeMode, value), + returnValueForMissingStub: null, + ); + + @override + set prefs(_i4.SharedPreferencesAsync? value) => super.noSuchMethod( + Invocation.setter(#prefs, value), + returnValueForMissingStub: null, + ); + + @override + set profile(_i10.Profile? value) => super.noSuchMethod( + Invocation.setter(#profile, value), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); + + @override + void clear() => super.noSuchMethod( + Invocation.method(#clear, []), + returnValueForMissingStub: null, + ); + + @override + void setThemeMode(_i9.ThemeMode? mode) => super.noSuchMethod( + Invocation.method(#setThemeMode, [mode]), + returnValueForMissingStub: null, + ); + + @override + _i6.Future fetchAndSetProfile() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetProfile, []), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + _i6.Future saveProfile() => + (super.noSuchMethod( + Invocation.method(#saveProfile, []), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + _i6.Future verifyEmail() => + (super.noSuchMethod( + Invocation.method(#verifyEmail, []), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#addListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#removeListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method(#notifyListeners, []), + returnValueForMissingStub: null, + ); +} diff --git a/test/weight/weight_form_test.dart b/test/weight/weight_form_test.dart deleted file mode 100644 index 0a821a377..000000000 --- a/test/weight/weight_form_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/body_weight/weight_entry.dart'; -import 'package:wger/widgets/weight/forms.dart'; - -import '../../test_data/body_weight.dart'; - -void main() { - Widget createWeightForm({locale = 'en', weightEntry = WeightEntry}) { - return MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: WeightForm(weightEntry)), - ); - } - - testWidgets('Correctly prefills and localizes the data - en', (WidgetTester tester) async { - await tester.pumpWidget(createWeightForm(weightEntry: testWeightEntry1)); - await tester.pumpAndSettle(); - - expect(find.text('1/1/2021'), findsOneWidget); - expect(find.text('3:30 PM'), findsOneWidget); - expect(find.text('80'), findsOneWidget); - }); - - testWidgets('Correctly prefills and localizes the data - de', (WidgetTester tester) async { - await tester.pumpWidget(createWeightForm(weightEntry: testWeightEntry1, locale: 'de')); - await tester.pumpAndSettle(); - - expect(find.text('1.1.2021'), findsOneWidget); - expect(find.text('15:30'), findsOneWidget); - expect(find.text('80'), findsOneWidget); - }); - - testWidgets('It is possible to quick-change the weight', (WidgetTester tester) async { - await tester.pumpWidget(createWeightForm(weightEntry: testWeightEntry1)); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('quickMinus'))); - expect(find.text('79'), findsOneWidget); - - await tester.tap(find.byKey(const Key('quickMinusSmall'))); - expect(find.text('78.9'), findsOneWidget); - - await tester.tap(find.byKey(const Key('quickPlus'))); - expect(find.text('79.9'), findsOneWidget); - - await tester.tap(find.byKey(const Key('quickPlusSmall'))); - expect(find.text('80'), findsOneWidget); - }); - - testWidgets("Entering garbage doesn't break the quick-change", (WidgetTester tester) async { - await tester.pumpWidget(createWeightForm(weightEntry: testWeightEntry1)); - await tester.pumpAndSettle(); - await tester.enterText(find.byKey(const Key('weightInput')), 'shiba inu'); - - await tester.tap(find.byKey(const Key('quickMinus'))); - expect(find.text('shiba inu'), findsOneWidget); - - await tester.tap(find.byKey(const Key('quickMinusSmall'))); - expect(find.text('shiba inu'), findsOneWidget); - - await tester.tap(find.byKey(const Key('quickPlus'))); - expect(find.text('shiba inu'), findsOneWidget); - - await tester.tap(find.byKey(const Key('quickPlusSmall'))); - expect(find.text('shiba inu'), findsOneWidget); - }); - - testWidgets('Widget works if there is no last entry', (WidgetTester tester) async { - await tester.pumpWidget(createWeightForm(weightEntry: null)); - await tester.pumpAndSettle(); - }); -} diff --git a/test/weight/weight_screen_test.dart b/test/weight/weight_screen_test.dart deleted file mode 100644 index b47f2ba75..000000000 --- a/test/weight/weight_screen_test.dart +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/providers/body_weight.dart'; -import 'package:wger/providers/nutrition.dart'; -import 'package:wger/providers/user.dart'; -import 'package:wger/screens/form_screen.dart'; -import 'package:wger/screens/weight_screen.dart'; -import 'package:wger/widgets/measurements/charts.dart'; -import 'package:wger/widgets/weight/forms.dart'; - -import '../../test_data/body_weight.dart'; -import '../../test_data/profile.dart'; -import 'weight_screen_test.mocks.dart'; - -@GenerateMocks([BodyWeightProvider, UserProvider, NutritionPlansProvider]) -void main() { - late MockBodyWeightProvider mockWeightProvider; - late MockUserProvider mockUserProvider; - late MockNutritionPlansProvider mockNutritionPlansProvider; - - setUp(() { - mockWeightProvider = MockBodyWeightProvider(); - when(mockWeightProvider.items).thenReturn(getWeightEntries()); - - mockUserProvider = MockUserProvider(); - when(mockUserProvider.profile).thenReturn(tProfile1); - - mockNutritionPlansProvider = MockNutritionPlansProvider(); - when(mockNutritionPlansProvider.currentPlan).thenReturn(null); - when(mockNutritionPlansProvider.items).thenReturn([]); - }); - - Widget createWeightScreen({locale = 'en'}) { - return MultiProvider( - providers: [ - ChangeNotifierProvider( - create: (ctx) => mockNutritionPlansProvider, - ), - ChangeNotifierProvider( - create: (context) => mockWeightProvider, - ), - ChangeNotifierProvider( - create: (context) => mockUserProvider, - ), - ], - child: MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const WeightScreen(), - routes: {FormScreen.routeName: (_) => const FormScreen()}, - ), - ); - } - - testWidgets('Test the widgets on the body weight screen', (WidgetTester tester) async { - await tester.pumpWidget(createWeightScreen()); - - expect(find.text('Weight'), findsOneWidget); - expect(find.byType(MeasurementChartWidgetFl), findsOneWidget); - expect(find.byType(Card), findsNWidgets(2)); - expect(find.byType(ListTile), findsNWidgets(2)); - }); - - testWidgets('Test deleting an item using the Delete button', (WidgetTester tester) async { - // Arrange - await tester.pumpWidget(createWeightScreen()); - - // Act - expect(find.byType(ListTile), findsNWidgets(2)); - await tester.tap(find.byTooltip('Show menu').first); - await tester.pumpAndSettle(); - - // Assert - await tester.tap(find.text('Delete')); - await tester.pumpAndSettle(); - verify(mockWeightProvider.deleteEntry(1)).called(1); - }); - - testWidgets('Test the form on the body weight screen', (WidgetTester tester) async { - await tester.pumpWidget(createWeightScreen()); - - expect(find.byType(WeightForm), findsNothing); - await tester.tap(find.byType(FloatingActionButton)); - await tester.pumpAndSettle(); - expect(find.byType(WeightForm), findsOneWidget); - }); - - testWidgets('Tests the localization of dates - EN', (WidgetTester tester) async { - await tester.pumpWidget(createWeightScreen()); - // these don't work because we only have 2 points, and to prevent overlaps we don't display their titles - // expect(find.text('1/1'), findsOneWidget); - // expect(find.text('1/10'), findsOneWidget); - }); - - testWidgets('Tests the localization of dates - DE', (WidgetTester tester) async { - await tester.pumpWidget(createWeightScreen(locale: 'de')); - // these don't work because we only have 2 points, and to prevent overlaps we don't display their titles - // expect(find.text('1.1.'), findsOneWidget); - // expect(find.text('10.1.'), findsOneWidget); - }); -}