Weight + Measurement Widget Redesign

This commit is contained in:
Luca-Wiehe
2025-12-30 11:09:45 +01:00
parent 994c962921
commit 61ded7d086
41 changed files with 5417 additions and 1431 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -3,16 +3,13 @@ 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_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 +20,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 +34,6 @@ Widget createWeightScreen({Locale? locale}) {
ChangeNotifierProvider<BodyWeightProvider>(
create: (context) => weightProvider,
),
ChangeNotifierProvider<NutritionPlansProvider>(
create: (context) => mockNutritionPlansProvider,
),
],
child: MaterialApp(
locale: locale,
@@ -51,8 +41,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(),
),
),
),
),
);

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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(),

View File

@@ -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<HomeTabsScreen> with SingleTickerProvid
const DashboardScreen(),
const RoutineListScreen(),
const NutritionalPlansScreen(),
const WeightScreen(),
const GalleryScreen(),
];
@@ -164,10 +161,6 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> 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,

View File

@@ -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<MeasurementCategoriesScreen> createState() => _MeasurementCategoriesScreenState();
}
class _MeasurementCategoriesScreenState extends State<MeasurementCategoriesScreen> {
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<MeasurementProvider>(
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),
),
],
),
),
),
);

View File

@@ -1,162 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<MeasurementProvider>(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<MeasurementOptions>(
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<MeasurementProvider>(
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<MeasurementOptions>(
value: MeasurementOptions.edit,
child: Text(AppLocalizations.of(context).edit),
),
PopupMenuItem<MeasurementOptions>(
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<MeasurementProvider>(
builder: (context, provider, child) => EntriesList(category!),
),
),
),
);
}
}

View File

@@ -1,60 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<BodyWeightProvider>(
builder: (context, provider, child) => WeightOverview(provider),
),
),
),
);
}
}

View File

@@ -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,

View File

@@ -0,0 +1,131 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<ChartTimeRange> 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),
),
),
),
),
);
}
}

View File

@@ -230,72 +230,75 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
@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<Event>(
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<Event>(
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<List<Event>>(
valueListenable: _selectedEvents,
builder: (context, value, _) => Column(
children: [
...value.map(
(event) => ListTile(
title: Text(
(() {
switch (event.type) {
case EventType.caloriesDiary:
return AppLocalizations.of(context).nutritionalDiary;
const SizedBox(height: 8.0),
ValueListenableBuilder<List<Event>>(
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.session:
return AppLocalizations.of(context).workoutSession;
case EventType.weight:
return AppLocalizations.of(context).weight;
case EventType.weight:
return AppLocalizations.of(context).weight;
case EventType.measurement:
return AppLocalizations.of(context).measurement;
}
})(),
case EventType.measurement:
return AppLocalizations.of(context).measurement;
}
})(),
),
subtitle: Text(event.description),
//onTap: () => print('$event tapped!'),
),
subtitle: Text(event.description),
//onTap: () => print('$event tapped!'),
),
),
],
],
),
),
),
],
],
),
),
);
}

View File

@@ -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<DashboardMeasurementWidget> {
int _current = 0;
final _controller = CarouselSliderController();
ChartTimeRange _selectedRange = ChartTimeRange.month;
@override
Widget build(BuildContext context) {
final provider = Provider.of<MeasurementProvider>(context, listen: false);
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final items = provider.categories
.map<Widget>((item) => CategoriesCard(item, elevation: 0))
.map<Widget>((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<MeasurementProvider>(
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,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -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<DashboardWeightWidget> createState() => _DashboardWeightWidgetState();
}
class _DashboardWeightWidgetState extends State<DashboardWeightWidget> {
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<UserProvider>().profile;
final weightProvider = context.read<BodyWeightProvider>();
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<BodyWeightProvider>(
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,
),
),
Column(
children: [
if (weightProvider.items.isNotEmpty)
Column(
children: [
SizedBox(
height: 200,
child: MeasurementChartWidgetFl(
entriesAll,
weightUnit(profile!.isMetric, context),
avgs: entries7dAvg,
),
),
if (entries7dAvg.isNotEmpty)
MeasurementOverallChangeWidget(
entries7dAvg.first,
entries7dAvg.last,
weightUnit(profile.isMetric, context),
),
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,
),
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
NothingFound(
AppLocalizations.of(context).noWeightEntries,
AppLocalizations.of(context).newEntry,
WeightForm(),
),
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,
),
],
),
],
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with entries button
Padding(
padding: const EdgeInsets.only(left: 16, right: 8, top: 12, bottom: 12),
child: Row(
children: [
Expanded(
child: Text(
AppLocalizations.of(context).weight,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
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,
),
),
),
),
],
),
),
// 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,
),
),
),
),
],
),
],
)
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,
),
),
),
),
],
),
),
],
),
),
],
),
),
),
),
);
}
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,
),
),
],
),
);
}
}

View File

@@ -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<MeasurementProvider>(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,
),
),
),
);
}

View File

@@ -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,
),
),
if (entries7dAvg.isNotEmpty)
MeasurementOverallChangeWidget(
entries7dAvg.first,
entries7dAvg.last,
currentCategory.unit,
),
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),
),
],
),
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,
),
],
),
),
),
// 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 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,
),
),
],
),
);
}

View File

@@ -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<MeasurementChartEntry> _entries;
final List<MeasurementChartEntry>? 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<MeasurementChartWidgetFl> createState() => _MeasurementChartWidgetFlState();
@@ -76,9 +80,17 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
}
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<MeasurementChartWidgetFl> {
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<double> _getVerticalLinePositions() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final positions = <double>[];
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<MeasurementChartWidgetFl> {
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<MeasurementChartWidgetFl> {
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<MeasurementChartWidgetFl> {
)
.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<MeasurementChartWidgetFl> {
)
.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<MeasurementChartEntry> moving7dAverage(List<MeasurementChartEntry> 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<MeasurementChartEntry>? movingAverage(List<MeasurementChartEntry> vals, int? days) {
if (days == null) return null;
var start = 0;
var end = 0;
final List<MeasurementChartEntry> out = <MeasurementChartEntry>[];
@@ -237,8 +526,8 @@ List<MeasurementChartEntry> moving7dAverage(List<MeasurementChartEntry> 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<MeasurementChartEntry> moving7dAverage(List<MeasurementChartEntry> vals) {
return out;
}
// Keep for backwards compatibility
List<MeasurementChartEntry> moving7dAverage(List<MeasurementChartEntry> vals) {
return movingAverage(vals, 7) ?? [];
}
class Indicator extends StatelessWidget {
const Indicator({
super.key,

View File

@@ -0,0 +1,607 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<FormState>();
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<void> _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<MeasurementProvider>(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<FormState>();
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<void> _saveCategory() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final provider = Provider.of<MeasurementProvider>(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);
}
}
}
}

View File

@@ -1,124 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<NutritionPlansProvider>(context, listen: false).items;
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
final provider = Provider.of<MeasurementProvider>(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,
),
),
);
}
},
),
];
},
),
),
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,485 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<MeasurementProvider>(
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<MeasurementProvider>(
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<MeasurementProvider>(
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<MeasurementProvider>(
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),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,456 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<FormState>();
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<void> _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<BodyWeightProvider>(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);
}
}
}
}

View File

@@ -0,0 +1,368 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<BodyWeightProvider>(
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<BodyWeightProvider>(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<UserProvider>().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<UserProvider>().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<BodyWeightProvider>(
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),
),
),
],
),
);
}
}

View File

@@ -1,226 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<FormState>();
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<BodyWeightProvider>(context, listen: false);
_weightEntry.id == null
? await provider.addEntry(_weightEntry)
: await provider.editEntry(_weightEntry);
if (context.mounted) {
Navigator.of(context).pop();
}
},
),
],
),
);
}
}

View File

@@ -1,135 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<UserProvider>().profile;
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
final plans = Provider.of<NutritionPlansProvider>(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,
),
),
);
}
},
),
];
},
),
),
);
},
),
),
),
],
);
}
}

View File

@@ -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<String>(
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<String>(this, Invocation.getter(#week)),
)
as String);
@override
String get month =>
(super.noSuchMethod(
Invocation.getter(#month),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#month),
),
)
as String);
@override
String get sixMonths =>
(super.noSuchMethod(
Invocation.getter(#sixMonths),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#sixMonths),
),
)
as String);
@override
String get year =>
(super.noSuchMethod(
Invocation.getter(#year),
returnValue: _i3.dummyValue<String>(this, Invocation.getter(#year)),
)
as String);
@override
String get recentEntries =>
(super.noSuchMethod(
Invocation.getter(#recentEntries),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#recentEntries),
),
)
as String);
@override
String get seeAll =>
(super.noSuchMethod(
Invocation.getter(#seeAll),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#seeAll),
),
)
as String);
@override
String exerciseNr(String? nr) =>
(super.noSuchMethod(

View File

@@ -0,0 +1,456 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<MeasurementProvider>.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<MeasurementProvider>.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);
});
});
}

View File

@@ -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<void> fetchAndSetCategories() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetCategories, []),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> fetchAndSetCategoryEntries(int? id) =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetCategoryEntries, [id]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> fetchAndSetAllCategoriesAndEntries() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetAllCategoriesAndEntries, []),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> addCategory(_i3.MeasurementCategory? category) =>
(super.noSuchMethod(
Invocation.method(#addCategory, [category]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> deleteCategory(int? id) =>
(super.noSuchMethod(
Invocation.method(#deleteCategory, [id]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> editCategory(int? id, String? newName, String? newUnit) =>
(super.noSuchMethod(
Invocation.method(#editCategory, [id, newName, newUnit]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> addEntry(_i6.MeasurementEntry? entry) =>
(super.noSuchMethod(
Invocation.method(#addEntry, [entry]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> deleteEntry(int? id, int? categoryId) =>
(super.noSuchMethod(
Invocation.method(#deleteEntry, [id, categoryId]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> editEntry(
int? id,
int? categoryId,
num? newValue,
String? newNotes,
DateTime? newDate,
) =>
(super.noSuchMethod(
Invocation.method(#editEntry, [
id,
categoryId,
newValue,
newNotes,
newDate,
]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@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,
);
}

View File

@@ -0,0 +1,317 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<MeasurementProvider>.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<MeasurementProvider>.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);
});
}

View File

@@ -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<void> fetchAndSetCategories() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetCategories, []),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> fetchAndSetCategoryEntries(int? id) =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetCategoryEntries, [id]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> fetchAndSetAllCategoriesAndEntries() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetAllCategoriesAndEntries, []),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> addCategory(_i3.MeasurementCategory? category) =>
(super.noSuchMethod(
Invocation.method(#addCategory, [category]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> deleteCategory(int? id) =>
(super.noSuchMethod(
Invocation.method(#deleteCategory, [id]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> editCategory(int? id, String? newName, String? newUnit) =>
(super.noSuchMethod(
Invocation.method(#editCategory, [id, newName, newUnit]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> addEntry(_i6.MeasurementEntry? entry) =>
(super.noSuchMethod(
Invocation.method(#addEntry, [entry]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> deleteEntry(int? id, int? categoryId) =>
(super.noSuchMethod(
Invocation.method(#deleteEntry, [id, categoryId]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> editEntry(
int? id,
int? categoryId,
num? newValue,
String? newNotes,
DateTime? newDate,
) =>
(super.noSuchMethod(
Invocation.method(#editEntry, [
id,
categoryId,
newValue,
newNotes,
newDate,
]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@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,
);
}

View File

@@ -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));
});
}

View File

@@ -1,117 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<NavigatorState>();
return ChangeNotifierProvider<NutritionPlansProvider>(
create: (context) => mockNutritionPlansProvider,
child: ChangeNotifierProvider<MeasurementProvider>(
create: (context) => mockMeasurementProvider,
child: MaterialApp(
locale: Locale(locale),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
navigatorKey: key,
home: TextButton(
onPressed: () => key.currentState!.push(
MaterialPageRoute<void>(
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);
});
}

View File

@@ -0,0 +1,244 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<BodyWeightProvider>.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();
});
}

View File

@@ -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<List<_i3.WeightEntry>> fetchAndSetEntries() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetEntries, []),
returnValue: _i5.Future<List<_i3.WeightEntry>>.value(
<_i3.WeightEntry>[],
),
)
as _i5.Future<List<_i3.WeightEntry>>);
@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<void> editEntry(_i3.WeightEntry? entry) =>
(super.noSuchMethod(
Invocation.method(#editEntry, [entry]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<void> deleteEntry(int? id) =>
(super.noSuchMethod(
Invocation.method(#deleteEntry, [id]),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@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,
);
}

View File

@@ -0,0 +1,234 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<WeightEntry> 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<BodyWeightProvider>.value(value: mockWeightProvider),
ChangeNotifierProvider<UserProvider>.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);
});
}

View File

@@ -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<List<_i3.WeightEntry>> fetchAndSetEntries() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetEntries, []),
returnValue: _i6.Future<List<_i3.WeightEntry>>.value(
<_i3.WeightEntry>[],
),
)
as _i6.Future<List<_i3.WeightEntry>>);
@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<void> editEntry(_i3.WeightEntry? entry) =>
(super.noSuchMethod(
Invocation.method(#editEntry, [entry]),
returnValue: _i6.Future<void>.value(),
returnValueForMissingStub: _i6.Future<void>.value(),
)
as _i6.Future<void>);
@override
_i6.Future<void> deleteEntry(int? id) =>
(super.noSuchMethod(
Invocation.method(#deleteEntry, [id]),
returnValue: _i6.Future<void>.value(),
returnValueForMissingStub: _i6.Future<void>.value(),
)
as _i6.Future<void>);
@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<void> fetchAndSetProfile() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetProfile, []),
returnValue: _i6.Future<void>.value(),
returnValueForMissingStub: _i6.Future<void>.value(),
)
as _i6.Future<void>);
@override
_i6.Future<void> saveProfile() =>
(super.noSuchMethod(
Invocation.method(#saveProfile, []),
returnValue: _i6.Future<void>.value(),
returnValueForMissingStub: _i6.Future<void>.value(),
)
as _i6.Future<void>);
@override
_i6.Future<void> verifyEmail() =>
(super.noSuchMethod(
Invocation.method(#verifyEmail, []),
returnValue: _i6.Future<void>.value(),
returnValueForMissingStub: _i6.Future<void>.value(),
)
as _i6.Future<void>);
@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,
);
}

View File

@@ -1,94 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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();
});
}

View File

@@ -1,124 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<NutritionPlansProvider>(
create: (ctx) => mockNutritionPlansProvider,
),
ChangeNotifierProvider<BodyWeightProvider>(
create: (context) => mockWeightProvider,
),
ChangeNotifierProvider<UserProvider>(
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);
});
}