Files
flutter/lib/widgets/workouts/forms.dart
Roland Geider f834950cec Workout logs and settings now have a reference to an exercise base
This puts this code in sync with the backend and is logically better, since
the translations can be displayed when needed and are not hard coded
2022-05-10 16:53:44 +02:00

895 lines
29 KiB
Dart

/*
* 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_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/exercises/base.dart';
import 'package:wger/models/workouts/day.dart';
import 'package:wger/models/workouts/repetition_unit.dart';
import 'package:wger/models/workouts/set.dart';
import 'package:wger/models/workouts/setting.dart';
import 'package:wger/models/workouts/weight_unit.dart';
import 'package:wger/models/workouts/workout_plan.dart';
import 'package:wger/providers/exercises.dart';
import 'package:wger/providers/workout_plans.dart';
import 'package:wger/screens/workout_plan_screen.dart';
import 'package:wger/theme/theme.dart';
import 'package:wger/widgets/exercises/images.dart';
class WorkoutForm extends StatelessWidget {
final WorkoutPlan _plan;
final _form = GlobalKey<FormState>();
WorkoutForm(this._plan);
final TextEditingController workoutNameController = TextEditingController();
final TextEditingController workoutDescriptionController = TextEditingController();
@override
Widget build(BuildContext context) {
if (_plan.id != null) {
workoutNameController.text = _plan.name;
workoutDescriptionController.text = _plan.description;
}
return Form(
key: _form,
child: Column(
children: [
TextFormField(
key: const Key('field-name'),
decoration: InputDecoration(labelText: AppLocalizations.of(context).name),
controller: workoutNameController,
validator: (value) {
const minLength = 1;
const maxLength = 100;
if (value!.isEmpty || value.length < minLength || value.length > maxLength) {
return AppLocalizations.of(context).enterCharacters(minLength, maxLength);
}
return null;
},
onFieldSubmitted: (_) {},
onSaved: (newValue) {
_plan.name = newValue!;
},
),
TextFormField(
key: const Key('field-description'),
decoration: InputDecoration(labelText: AppLocalizations.of(context).description),
minLines: 3,
maxLines: 10,
controller: workoutDescriptionController,
validator: (value) {
const minLength = 0;
const maxLength = 1000;
if (value!.length > maxLength) {
return AppLocalizations.of(context).enterCharacters(minLength, maxLength);
}
return null;
},
onFieldSubmitted: (_) {},
onSaved: (newValue) {
_plan.description = newValue!;
},
),
ElevatedButton(
key: const Key(SUBMIT_BUTTON_KEY_NAME),
child: Text(AppLocalizations.of(context).save),
onPressed: () async {
// Validate and save
final isValid = _form.currentState!.validate();
if (!isValid) {
return;
}
_form.currentState!.save();
// Save to DB
if (_plan.id != null) {
await Provider.of<WorkoutPlansProvider>(context, listen: false).editWorkout(_plan);
Navigator.of(context).pop();
} else {
final WorkoutPlan newPlan =
await Provider.of<WorkoutPlansProvider>(context, listen: false)
.addWorkout(_plan);
Navigator.of(context).pushReplacementNamed(
WorkoutPlanScreen.routeName,
arguments: newPlan,
);
}
},
),
],
),
);
}
}
class DayCheckbox extends StatefulWidget {
final Day _day;
final int _dayNr;
const DayCheckbox(this._dayNr, this._day);
@override
_DayCheckboxState createState() => _DayCheckboxState();
}
class _DayCheckboxState extends State<DayCheckbox> {
@override
Widget build(BuildContext context) {
return CheckboxListTile(
key: Key('field-checkbox-${widget._dayNr}'),
title: Text(widget._day.getDayTranslated(
widget._dayNr,
Localizations.localeOf(context).languageCode,
)),
value: widget._day.daysOfWeek.contains(widget._dayNr),
onChanged: (bool? newValue) {
setState(() {
if (!newValue!) {
widget._day.daysOfWeek.remove(widget._dayNr);
} else {
widget._day.daysOfWeek.add(widget._dayNr);
}
});
},
);
}
}
class DayFormWidget extends StatefulWidget {
final WorkoutPlan workout;
final dayController = TextEditingController();
late final Day _day;
DayFormWidget(this.workout, [Day? day]) {
_day = day ?? Day();
_day.workoutId = workout.id!;
if (_day.id != null) {
dayController.text = day!.description;
}
}
@override
_DayFormWidgetState createState() => _DayFormWidgetState();
}
class _DayFormWidgetState extends State<DayFormWidget> {
final _form = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _form,
child: ListView(
children: [
TextFormField(
key: const Key('field-description'),
decoration: InputDecoration(
labelText: AppLocalizations.of(context).description,
helperText: AppLocalizations.of(context).dayDescriptionHelp,
helperMaxLines: 3,
),
controller: widget.dayController,
onSaved: (value) {
widget._day.description = value!;
},
validator: (value) {
const minLength = 1;
const maxLength = 100;
if (value!.isEmpty || value.length < minLength || value.length > maxLength) {
return AppLocalizations.of(context).enterCharacters(minLength, maxLength);
}
if (widget._day.daysOfWeek.isEmpty) {
return 'You need to select at least one day';
}
return null;
},
),
const SizedBox(height: 10),
...Day.weekdays.keys.map((dayNr) => DayCheckbox(dayNr, widget._day)).toList(),
ElevatedButton(
key: const Key(SUBMIT_BUTTON_KEY_NAME),
child: Text(AppLocalizations.of(context).save),
onPressed: () async {
if (!_form.currentState!.validate()) {
return;
}
_form.currentState!.save();
try {
if (widget._day.id == null) {
Provider.of<WorkoutPlansProvider>(context, listen: false).addDay(
widget._day,
widget.workout,
);
} else {
Provider.of<WorkoutPlansProvider>(context, listen: false).editDay(
widget._day,
);
}
widget.dayController.clear();
Navigator.of(context).pop();
} catch (error) {
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('An error occurred!'),
content: const Text('Something went wrong.'),
actions: [
TextButton(
child: const Text('Okay'),
onPressed: () {
Navigator.of(ctx).pop();
},
)
],
),
);
}
},
),
],
),
);
}
}
class SetFormWidget extends StatefulWidget {
final Day _day;
late final Set _set;
SetFormWidget(this._day, [Set? set]) {
_set = set ?? Set.withData(day: _day.id, order: _day.sets.length, sets: 4);
}
@override
_SetFormWidgetState createState() => _SetFormWidgetState();
}
class _SetFormWidgetState extends State<SetFormWidget> {
double _currentSetSliderValue = Set.DEFAULT_NR_SETS.toDouble();
bool _detailed = false;
// Form stuff
final GlobalKey<FormState> _formKey = GlobalKey();
final _exercisesController = TextEditingController();
/// Removes an exercise from the current set
void removeExerciseBase(ExerciseBase base) {
setState(() {
widget._set.removeExercise(base);
});
}
/// Adds an exercise to the current set
void addExercise(ExerciseBase base) {
setState(() {
widget._set.addExerciseBase(base);
addSettings();
});
}
/// Adds settings to the set
void addSettings() {
widget._set.settings = [];
int order = 0;
for (final exercise in widget._set.exerciseBasesObj) {
order++;
for (int loop = 0; loop < widget._set.sets; loop++) {
final Setting setting = Setting.empty();
setting.order = order;
setting.exerciseBase = exercise;
setting.weightUnit =
Provider.of<WorkoutPlansProvider>(context, listen: false).defaultWeightUnit;
setting.repetitionUnit =
Provider.of<WorkoutPlansProvider>(context, listen: false).defaultRepetitionUnit;
widget._set.settings.add(setting);
}
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: ListView(
children: [
Container(
padding: const EdgeInsets.only(top: 10),
color: wgerPrimaryColorLight,
child: Column(
//crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context).nrOfSets(_currentSetSliderValue.round())),
Slider(
value: _currentSetSliderValue,
min: 1,
max: 10,
divisions: 10,
label: _currentSetSliderValue.round().toString(),
onChanged: (double value) {
setState(() {
widget._set.sets = value.round();
_currentSetSliderValue = value;
addSettings();
});
},
),
if (widget._set.settings.isNotEmpty)
SwitchListTile(
title: Text(AppLocalizations.of(context).setUnitsAndRir),
value: _detailed,
onChanged: (value) {
setState(() {
_detailed = !_detailed;
});
},
),
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Card(
child: TypeAheadFormField<ExerciseBase>(
key: const Key('field-typeahead'),
textFieldConfiguration: TextFieldConfiguration(
controller: _exercisesController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).searchExercise,
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.help),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context).selectExercises),
const SizedBox(height: 10),
Text(AppLocalizations.of(context).sameRepetitions)
],
),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
);
},
),
errorMaxLines: 2,
),
),
suggestionsCallback: (pattern) {
return Provider.of<ExercisesProvider>(context, listen: false).searchExercise(
pattern,
Localizations.localeOf(context).languageCode,
);
},
itemBuilder: (BuildContext context, ExerciseBase exerciseSuggestion) {
return ListTile(
leading: SizedBox(
width: 45,
child: ExerciseImageWidget(image: exerciseSuggestion.getMainImage),
),
title: Text(exerciseSuggestion
.getExercise(Localizations.localeOf(context).languageCode)
.name),
subtitle: Text(
'${exerciseSuggestion.category.name} / ${exerciseSuggestion.equipment.map((e) => e.name).join(', ')}',
),
);
},
transitionBuilder: (context, suggestionsBox, controller) {
return suggestionsBox;
},
onSuggestionSelected: (ExerciseBase exerciseSuggestion) {
addExercise(exerciseSuggestion);
this._exercisesController.text = '';
},
validator: (value) {
// At least one exercise must be selected
if (widget._set.exerciseBasesIds.isEmpty) {
return AppLocalizations.of(context).selectExercise;
}
// At least one setting has to be filled in
if (widget._set.settings
.where((s) => s.weight == null && s.reps == null)
.length ==
widget._set.settings.length) {
return AppLocalizations.of(context).enterRepetitionsOrWeight;
}
return null;
},
),
),
const SizedBox(height: 10),
TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).comment,
errorMaxLines: 2,
),
keyboardType: TextInputType.text,
validator: (value) {
const minLength = 0;
const maxLength = 200;
if (value!.length > maxLength) {
return AppLocalizations.of(context).enterCharacters(minLength, maxLength);
}
return null;
},
onSaved: (newValue) {
widget._set.comment = newValue!;
},
),
const SizedBox(height: 10),
...widget._set.exerciseBasesObj.asMap().entries.map((entry) {
final index = entry.key;
final exercise = entry.value;
final showSupersetInfo = (index + 1) < widget._set.exerciseBasesObj.length;
final settings = widget._set.settings
.where((e) => e.exerciseBaseObj.id == exercise.id)
.toList();
return Column(
children: [
ExerciseSetting(
exercise,
settings,
_detailed,
_currentSetSliderValue,
removeExerciseBase,
),
if (showSupersetInfo)
const Padding(
padding: EdgeInsets.all(3.0),
child: Text('+'),
),
if (showSupersetInfo) Text(AppLocalizations.of(context).supersetWith),
if (showSupersetInfo)
const Padding(
padding: EdgeInsets.all(3.0),
child: Text('+'),
),
],
);
}).toList(),
ElevatedButton(
key: const Key(SUBMIT_BUTTON_KEY_NAME),
child: Text(AppLocalizations.of(context).save),
onPressed: () async {
final isValid = _formKey.currentState!.validate();
if (!isValid) {
return;
}
_formKey.currentState!.save();
final workoutProvider =
Provider.of<WorkoutPlansProvider>(context, listen: false);
// Save set
final Set setDb = await workoutProvider.addSet(widget._set);
widget._set.id = setDb.id;
// Remove unused settings
widget._set.settings.removeWhere((s) => s.weight == null && s.reps == null);
// Save remaining settings
for (final setting in widget._set.settings) {
setting.setId = setDb.id!;
setting.comment = '';
final Setting settingDb = await workoutProvider.addSetting(setting);
setting.id = settingDb.id;
}
// Add to workout day
workoutProvider.fetchComputedSettings(widget._set);
widget._day.sets.add(widget._set);
// Close the bottom sheet
Navigator.of(context).pop();
},
),
],
),
),
],
),
);
}
}
class ExerciseSetting extends StatelessWidget {
final ExerciseBase _exerciseBase;
late final int _numberOfSets;
final bool _detailed;
final Function removeExercise;
final List<Setting> _settings;
ExerciseSetting(
this._exerciseBase,
this._settings,
this._detailed,
double sliderValue,
this.removeExercise,
) {
_numberOfSets = sliderValue.round();
}
Widget getRows(BuildContext context) {
final List<Widget> out = [];
for (var i = 0; i < _numberOfSets; i++) {
final setting = _settings[i];
if (_detailed) {
out.add(
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
AppLocalizations.of(context).setNr(i + 1),
style: Theme.of(context).textTheme.headline6,
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
flex: 2,
child: RepsInputWidget(setting, _detailed),
),
const SizedBox(width: 4),
Flexible(
flex: 3,
child: RepetitionUnitInputWidget(setting),
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
flex: 2,
child: WeightInputWidget(setting, _detailed),
),
const SizedBox(width: 4),
Flexible(
flex: 3,
child: WeightUnitInputWidget(setting, key: Key(i.toString())),
),
],
),
Flexible(
flex: 2,
child: RiRInputWidget(setting),
),
const SizedBox(height: 15),
],
),
);
} else {
out.add(
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
AppLocalizations.of(context).setNr(i + 1),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
Flexible(child: RepsInputWidget(setting, _detailed)),
const SizedBox(width: 4),
Flexible(child: WeightInputWidget(setting, _detailed)),
],
),
);
}
}
return Column(
children: out,
);
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(15),
child: Column(
children: [
ListTile(
title: Text(
_exerciseBase.getExercise(Localizations.localeOf(context).languageCode).name,
style: Theme.of(context).textTheme.headline6,
),
subtitle: Text(_exerciseBase.category.name),
contentPadding: EdgeInsets.zero,
leading: ExerciseImageWidget(image: _exerciseBase.getMainImage),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
removeExercise(_exerciseBase);
}),
),
const Divider(),
//ExerciseImage(imageUrl: _exercise.images.first.url),
if (!_detailed)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(AppLocalizations.of(context).repetitions),
Text(AppLocalizations.of(context).weight),
],
),
getRows(context),
],
),
),
);
}
}
class RepsInputWidget extends StatelessWidget {
final _repsController = TextEditingController();
final Setting _setting;
final bool _detailed;
RepsInputWidget(this._setting, this._detailed);
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
labelText: _detailed ? AppLocalizations.of(context).repetitions : '',
errorMaxLines: 2,
),
controller: _repsController,
keyboardType: TextInputType.number,
validator: (value) {
try {
if (value != '') {
double.parse(value!);
}
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
},
onChanged: (newValue) {
if (newValue != '') {
try {
_setting.reps = int.parse(newValue);
} catch (e) {}
}
},
);
}
}
class WeightInputWidget extends StatelessWidget {
final _weightController = TextEditingController();
final Setting _setting;
final bool _detailed;
WeightInputWidget(this._setting, this._detailed);
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
labelText: _detailed ? AppLocalizations.of(context).weight : '',
errorMaxLines: 2,
),
controller: _weightController,
keyboardType: TextInputType.number,
validator: (value) {
try {
if (value != '') {
double.parse(value!);
}
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
},
onChanged: (newValue) {
if (newValue != '') {
try {
_setting.weight = double.parse(newValue);
} catch (e) {}
}
},
);
}
}
/// Input widget for Rests In Reserve
///
/// Can be used with a Setting or a Log object
class RiRInputWidget extends StatefulWidget {
final dynamic _setting;
late String dropdownValue;
late double _currentSetSliderValue;
static const SLIDER_START = -0.5;
RiRInputWidget(this._setting) {
dropdownValue = _setting.rir ?? Setting.DEFAULT_RIR;
// Read string RiR into a double
if (_setting.rir != null) {
if (_setting.rir == '') {
_currentSetSliderValue = SLIDER_START;
} else {
_currentSetSliderValue = double.parse(_setting.rir);
}
} else {
_currentSetSliderValue = SLIDER_START;
}
}
@override
_RiRInputWidgetState createState() => _RiRInputWidgetState();
}
class _RiRInputWidgetState extends State<RiRInputWidget> {
/// Returns the string used in the slider
String getSliderLabel(double value) {
if (value < 0) {
return AppLocalizations.of(context).rirNotUsed;
}
return '${value.toString()} ${AppLocalizations.of(context).rir}';
}
String mapDoubleToAllowedRir(double value) {
if (value < 0) {
return '';
} else {
// The representation is different (3.0 -> 3) we are on an int, round
if (value.toInt() < value) {
return value.toString();
} else {
return value.toInt().toString();
}
}
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(AppLocalizations.of(context).rir),
Expanded(
child: Slider(
value: widget._currentSetSliderValue,
min: RiRInputWidget.SLIDER_START,
max: (Setting.POSSIBLE_RIR_VALUES.length - 2) / 2,
divisions: Setting.POSSIBLE_RIR_VALUES.length - 1,
label: getSliderLabel(widget._currentSetSliderValue),
onChanged: (double value) {
widget._setting.setRir(mapDoubleToAllowedRir(value));
setState(() {
widget._currentSetSliderValue = value;
});
},
),
),
],
);
}
}
/// Input widget for workout weight units
///
/// Can be used with a Setting or a Log object
class WeightUnitInputWidget extends StatefulWidget {
final dynamic _setting;
const WeightUnitInputWidget(this._setting, {Key? key}) : super(key: key);
@override
_WeightUnitInputWidgetState createState() => _WeightUnitInputWidgetState();
}
class _WeightUnitInputWidgetState extends State<WeightUnitInputWidget> {
@override
Widget build(BuildContext context) {
WeightUnit selectedWeightUnit = widget._setting.weightUnitObj;
return DropdownButtonFormField(
value: selectedWeightUnit,
decoration: InputDecoration(labelText: AppLocalizations.of(context).weightUnit),
onChanged: (WeightUnit? newValue) {
setState(() {
selectedWeightUnit = newValue!;
widget._setting.weightUnit = newValue;
});
},
items: Provider.of<WorkoutPlansProvider>(context, listen: false)
.weightUnits
.map<DropdownMenuItem<WeightUnit>>((WeightUnit value) {
return DropdownMenuItem<WeightUnit>(
key: Key(value.id.toString()),
value: value,
child: Text(value.name),
);
}).toList(),
);
}
}
/// Input widget for repetition units
///
/// Can be used with a Setting or a Log object
class RepetitionUnitInputWidget extends StatefulWidget {
final dynamic _setting;
const RepetitionUnitInputWidget(this._setting);
@override
_RepetitionUnitInputWidgetState createState() => _RepetitionUnitInputWidgetState();
}
class _RepetitionUnitInputWidgetState extends State<RepetitionUnitInputWidget> {
@override
Widget build(BuildContext context) {
RepetitionUnit selectedWeightUnit = widget._setting.repetitionUnitObj;
return DropdownButtonFormField(
value: selectedWeightUnit,
decoration: InputDecoration(labelText: AppLocalizations.of(context).repetitionUnit),
isDense: true,
onChanged: (RepetitionUnit? newValue) {
setState(() {
selectedWeightUnit = newValue!;
widget._setting.repetitionUnit = newValue;
});
},
items: Provider.of<WorkoutPlansProvider>(context, listen: false)
.repetitionUnits
.map<DropdownMenuItem<RepetitionUnit>>((RepetitionUnit value) {
return DropdownMenuItem<RepetitionUnit>(
key: Key(value.id.toString()),
value: value,
child: Text(value.name),
);
}).toList(),
);
}
}