mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
Weight + Measurement Widget Redesign
This commit is contained in:
BIN
assets/icons/measuring-tape.png
Normal file
BIN
assets/icons/measuring-tape.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
131
lib/widgets/core/time_range_tab_bar.dart
Normal file
131
lib/widgets/core/time_range_tab_bar.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
607
lib/widgets/measurements/edit_modals.dart
Normal file
607
lib/widgets/measurements/edit_modals.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
485
lib/widgets/measurements/entries_modal.dart
Normal file
485
lib/widgets/measurements/entries_modal.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
456
lib/widgets/weight/edit_modal.dart
Normal file
456
lib/widgets/weight/edit_modal.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
368
lib/widgets/weight/entries_modal.dart
Normal file
368
lib/widgets/weight/entries_modal.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
456
test/measurements/edit_modals_test.dart
Normal file
456
test/measurements/edit_modals_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
204
test/measurements/edit_modals_test.mocks.dart
Normal file
204
test/measurements/edit_modals_test.mocks.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
317
test/measurements/entries_modal_test.dart
Normal file
317
test/measurements/entries_modal_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
204
test/measurements/entries_modal_test.mocks.dart
Normal file
204
test/measurements/entries_modal_test.mocks.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
244
test/weight/weight_edit_modal_test.dart
Normal file
244
test/weight/weight_edit_modal_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
157
test/weight/weight_edit_modal_test.mocks.dart
Normal file
157
test/weight/weight_edit_modal_test.mocks.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
234
test/weight/weight_entries_modal_test.dart
Normal file
234
test/weight/weight_entries_modal_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
290
test/weight/weight_entries_modal_test.mocks.dart
Normal file
290
test/weight/weight_entries_modal_test.mocks.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user