Make RiR form element a slider

This allows the user to select the correct value faster
This commit is contained in:
Roland Geider
2021-05-19 21:35:07 +02:00
parent bd1d75caec
commit e45115d26e
5 changed files with 203 additions and 142 deletions

View File

@@ -140,6 +140,10 @@
"@rir": {
"description": "Shorthand for Repetitions In Reserve"
},
"rirNotUsed": "RiR not used",
"@rirNotUsed": {
"description": "Label used in RiR slider when the RiR value is not used/saved for the current setting or log"
},
"weightUnit": "Weight unit",
"@weightUnit": {},
"repetitionUnit": "Repetition unit",
@@ -188,6 +192,14 @@
"@gymMode": {
"description": "Label when starting the gym mode"
},
"pause": "Pause",
"@pause": {
"description": "Noun, not an imperative! Label used for the pause when using the gym mode"
},
"jumpTo": "Jump to",
"@jumpTo": {
"description": "Imperative. Label used in popup allowing the user to jump to a specific exercise while in the gym mode"
},
"todaysWorkout": "Your workout today",
"@todaysWorkout": {},
"logHelpEntries": "If on a single day there is more than one entry with the same number of repetitions, but different weights, only the entry with the higher weight is shown in the diagram.",

View File

@@ -106,11 +106,11 @@ class Setting {
repetitionUnitId = repetitionUnit.id;
}
void setRir(String rir) {
if (POSSIBLE_RIR_VALUES.contains(rir)) {
this.rir = rir;
void setRir(String newRir) {
if (POSSIBLE_RIR_VALUES.contains(newRir)) {
this.rir = newRir;
} else {
throw Exception('RiR value not allowed');
throw Exception('RiR value not allowed: $newRir');
}
}

View File

@@ -703,8 +703,23 @@ class WeightInputWidget extends StatelessWidget {
class RiRInputWidget extends StatefulWidget {
final dynamic _setting;
late String dropdownValue;
late double _currentSetSliderValue;
final SLIDER_START = -0.5;
RiRInputWidget(this._setting) {
dropdownValue = _setting.rir != null ? _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
@@ -712,25 +727,49 @@ class RiRInputWidget extends StatefulWidget {
}
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 DropdownButtonFormField(
decoration: InputDecoration(labelText: AppLocalizations.of(context).rir),
value: widget.dropdownValue,
onSaved: (String? newValue) {
widget._setting.setRir(newValue!);
},
onChanged: (String? newValue) {
setState(() {
widget.dropdownValue = newValue!;
});
},
items: Setting.POSSIBLE_RIR_VALUES.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
return Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(AppLocalizations.of(context).rir),
Expanded(
child: Slider(
value: widget._currentSetSliderValue,
min: widget.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;
});
},
),
),
],
);
}
}

View File

@@ -245,6 +245,7 @@ class LogPage extends StatefulWidget {
_log.exercise = _exercise;
_log.weightUnit = _setting.weightUnitObj;
_log.repetitionUnit = _setting.repetitionUnitObj;
_log.rir = _setting.rir;
}
@override
@@ -256,7 +257,6 @@ class _LogPageState extends State<LogPage> {
String rirValue = Setting.DEFAULT_RIR;
final _repsController = TextEditingController();
final _weightController = TextEditingController();
final _rirController = TextEditingController();
var _detailed = false;
@override
@@ -270,10 +270,6 @@ class _LogPageState extends State<LogPage> {
if (widget._setting.weight != null) {
_weightController.text = widget._setting.weight.toString();
}
if (widget._setting.rir != null) {
_rirController.text = widget._setting.rir!;
}
}
Widget getRepsWidget() {
@@ -373,6 +369,124 @@ class _LogPageState extends State<LogPage> {
);
}
Widget getForm() {
return Form(
key: _form,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
AppLocalizations.of(context).newEntry,
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
),
if (!_detailed)
Row(
children: [
Flexible(child: getRepsWidget()),
SizedBox(width: 8),
Flexible(child: getWeightWidget()),
],
),
if (_detailed)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(child: getRepsWidget()),
SizedBox(width: 8),
Flexible(child: RepetitionUnitInputWidget(widget._log)),
],
),
if (_detailed)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(child: getWeightWidget()),
SizedBox(width: 8),
Flexible(child: WeightUnitInputWidget(widget._log))
],
),
if (_detailed) RiRInputWidget(widget._log),
SwitchListTile(
title: Text(AppLocalizations.of(context).setUnitsAndRir),
value: _detailed,
onChanged: (value) {
setState(() {
_detailed = !_detailed;
});
},
),
ElevatedButton(
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
try {
await Provider.of<WorkoutPlansProvider>(context, listen: false).addLog(widget._log);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: Duration(seconds: 2), // default is 4
content: Text(
AppLocalizations.of(context).successfullySaved,
textAlign: TextAlign.center,
),
),
);
widget._controller.nextPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
} on WgerHttpException catch (error) {
showHttpExceptionErrorDialog(error, context);
} catch (error) {
showErrorDialog(error, context);
}
},
),
],
),
);
}
Widget getPastLogs() {
return ListView(
children: [
Text(
AppLocalizations.of(context).labelWorkoutLogs,
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
),
...widget._workoutPlan.filterLogsByExercise(widget._exercise, unique: true).map((log) {
return ListTile(
title: Text(log.singleLogRepText.replaceAll('\n', '')),
subtitle:
Text(DateFormat.yMd(Localizations.localeOf(context).languageCode).format(log.date)),
trailing: Icon(Icons.copy),
onTap: () {
setState(() {
// Text field
_repsController.text = log.reps.toString();
_weightController.text = log.weight.toString();
// Drop downs
widget._log.rir = log.rir;
widget._log.repetitionUnit = log.repetitionUnitObj;
widget._log.weightUnit = log.weightUnitObj;
});
},
contentPadding: EdgeInsets.symmetric(horizontal: 40),
);
}).toList(),
],
);
}
@override
Widget build(BuildContext context) {
return Column(
@@ -392,119 +506,14 @@ class _LogPageState extends State<LogPage> {
SizedBox(height: 10),
Expanded(
child: (widget._workoutPlan.filterLogsByExercise(widget._exercise).length > 0)
? ListView(
children: [
Text(
AppLocalizations.of(context).labelWorkoutLogs,
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
),
...widget._workoutPlan
.filterLogsByExercise(widget._exercise, unique: true)
.map((log) {
return ListTile(
title: Text(log.singleLogRepText.replaceAll('\n', '')),
subtitle: Text(
DateFormat.yMd(Localizations.localeOf(context).languageCode)
.format(log.date)),
trailing: Icon(Icons.copy),
onTap: () {
setState(() {
// Text field
_repsController.text = log.reps.toString();
_weightController.text = log.weight.toString();
// Drop downs
widget._log.rir = log.rir;
widget._log.repetitionUnit = log.repetitionUnitObj;
widget._log.weightUnit = log.weightUnitObj;
});
},
contentPadding: EdgeInsets.symmetric(horizontal: 40),
);
}).toList(),
],
)
? getPastLogs()
: Container()),
SizedBox(height: 15),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Form(
key: _form,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_detailed)
Row(
children: [
Flexible(child: getRepsWidget()),
SizedBox(width: 8),
Flexible(child: getWeightWidget()),
],
),
if (_detailed)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(child: getRepsWidget()),
SizedBox(width: 8),
Flexible(child: RepetitionUnitInputWidget(widget._log)),
],
),
if (_detailed)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(child: getWeightWidget()),
SizedBox(width: 8),
Flexible(child: WeightUnitInputWidget(widget._log))
],
),
if (_detailed) RiRInputWidget(widget._log),
IconButton(
icon: Icon(_detailed ? Icons.unfold_less : Icons.unfold_more),
onPressed: () {
setState(() {
_detailed = !_detailed;
});
},
),
ElevatedButton(
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
try {
await Provider.of<WorkoutPlansProvider>(context, listen: false)
.addLog(widget._log);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: Duration(seconds: 2), // default is 4
content: Text(
AppLocalizations.of(context).successfullySaved,
textAlign: TextAlign.center,
),
),
);
widget._controller.nextPage(
duration: Duration(milliseconds: 200),
curve: Curves.bounceIn,
);
} on WgerHttpException catch (error) {
showHttpExceptionErrorDialog(error, context);
} catch (error) {
showErrorDialog(error, context);
}
},
),
],
),
child: Card(
color: wgerBackground,
child: getForm(),
),
),
NavigationFooter(widget._controller, widget._ratioCompleted),
@@ -822,7 +831,7 @@ class _TimerWidgetState extends State<TimerWidget> {
return Column(
children: [
NavigationHeader(
'',
AppLocalizations.of(context).pause,
widget._controller,
exercisePages: widget._exercisePages,
),
@@ -900,7 +909,10 @@ class NavigationHeader extends StatelessWidget {
Widget getDialog(BuildContext context) {
return AlertDialog(
title: Text('Jump to', textAlign: TextAlign.center),
title: Text(
AppLocalizations.of(context).jumpTo,
textAlign: TextAlign.center,
),
contentPadding: EdgeInsets.zero,
content: SingleChildScrollView(
child: Column(

View File

@@ -105,7 +105,7 @@ void main() {
expect(find.text('test exercise 1'), findsOneWidget);
expect(find.byType(LogPage), findsOneWidget);
expect(find.byType(Form), findsOneWidget);
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.byType(ListTile), findsNWidgets(3), reason: 'Two logs and the switch tile');
expect(find.text('10 × 10 kg (1.5 RiR)'), findsOneWidget);
expect(find.text('12 × 10 kg (2 RiR)'), findsOneWidget);
expect(find.byIcon(Icons.close), findsOneWidget);
@@ -114,20 +114,18 @@ void main() {
expect(find.byIcon(Icons.chevron_right), findsOneWidget);
// Form shows only weight and reps
expect(find.byIcon(Icons.unfold_more), findsOneWidget);
expect(find.byType(SwitchListTile), findsOneWidget);
expect(find.byType(TextFormField), findsNWidgets(2));
expect(find.byType(RepetitionUnitInputWidget), findsNothing);
expect(find.byType(WeightUnitInputWidget), findsNothing);
expect(find.byType(RiRInputWidget), findsNothing);
// Form shows unit and rir after tapping the toggle button
await tester.tap(find.byIcon(Icons.unfold_more));
await tester.tap(find.byType(SwitchListTile));
await tester.pump();
expect(find.byType(RepetitionUnitInputWidget), findsOneWidget);
expect(find.byType(WeightUnitInputWidget), findsOneWidget);
expect(find.byType(RiRInputWidget), findsOneWidget);
expect(find.byIcon(Icons.unfold_less), findsOneWidget);
await tester.drag(find.byType(LogPage), Offset(-500.0, 0.0));
await tester.pumpAndSettle();