Files
flutter/lib/widgets/workouts/gym_mode.dart

1094 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/gym_mode.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/helpers/misc.dart';
import 'package:wger/helpers/ui.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/http_exception.dart';
import 'package:wger/models/workouts/day.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/session.dart';
import 'package:wger/models/workouts/set.dart';
import 'package:wger/models/workouts/setting.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/theme/theme.dart';
import 'package:wger/widgets/core/core.dart';
import 'package:wger/widgets/exercises/images.dart';
import 'package:wger/widgets/workouts/forms.dart';
class GymMode extends StatefulWidget {
final Day _workoutDay;
late TimeOfDay _start;
GymMode(this._workoutDay) {
_start = TimeOfDay.now();
}
@override
_GymModeState createState() => _GymModeState();
}
class _GymModeState extends State<GymMode> {
var _totalElements = 1;
/// Map with the first (navigation) page for each exercise
Map<String, int> _exercisePages = new Map();
PageController _controller = PageController(
initialPage: 0,
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
// Calculate amount of elements for progress indicator
for (var set in widget._workoutDay.sets) {
_totalElements = _totalElements + set.settingsComputed.length;
}
// Calculate the pages for the navigation
//
// This duplicates the code below in the getContent method, but it seems to
// be the easiest way
var currentPage = 1;
for (var set in widget._workoutDay.sets) {
var firstPage = true;
for (var setting in set.settingsComputed) {
final exercise =
Provider.of<ExercisesProvider>(context, listen: false).findById(setting.exerciseId);
if (firstPage) {
_exercisePages[exercise.name] = currentPage;
currentPage++;
}
// Log Page
currentPage++;
// Timer
currentPage++;
firstPage = false;
}
}
}
// Returns the list of exercise overview, sets and pause pages
List<Widget> getContent() {
final exerciseProvider = Provider.of<ExercisesProvider>(context, listen: false);
final workoutProvider = Provider.of<WorkoutPlansProvider>(context, listen: false);
var currentElement = 1;
List<Widget> out = [];
for (var set in widget._workoutDay.sets) {
var firstPage = true;
for (var setting in set.settingsComputed) {
var ratioCompleted = currentElement / _totalElements;
final exercise = exerciseProvider.findById(setting.exerciseId);
currentElement++;
if (firstPage) {
out.add(ExerciseOverview(
_controller,
exercise,
ratioCompleted,
_exercisePages,
));
}
out.add(LogPage(
_controller,
setting,
set,
exercise,
workoutProvider.findById(widget._workoutDay.workoutId),
ratioCompleted,
_exercisePages,
));
out.add(TimerWidget(_controller, ratioCompleted, _exercisePages));
firstPage = false;
}
}
return out;
}
@override
Widget build(BuildContext context) {
return PageView(
controller: _controller,
children: [
StartPage(
_controller,
widget._workoutDay,
_exercisePages,
),
...getContent(),
SessionPage(
Provider.of<WorkoutPlansProvider>(context, listen: false)
.findById(widget._workoutDay.workoutId),
_controller,
widget._start,
_exercisePages,
),
],
);
}
}
class StartPage extends StatelessWidget {
PageController _controller;
final Day _day;
Map<String, int> _exercisePages;
StartPage(this._controller, this._day, this._exercisePages);
@override
Widget build(BuildContext context) {
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).todaysWorkout,
_controller,
exercisePages: _exercisePages,
),
Divider(),
Expanded(
child: ListView(
children: [
..._day.sets.map(
(set) {
return Column(
children: [
...set.settingsFiltered.map((s) {
return Column(
children: [
Text(
s.exerciseObj.name,
style: Theme.of(context).textTheme.headline6,
),
...set.getSmartRepr(s.exerciseObj).map((e) => Text(e)).toList(),
SizedBox(height: 15),
],
);
}).toList(),
],
);
},
).toList(),
],
),
),
ElevatedButton(
child: Text(AppLocalizations.of(context).start),
onPressed: () {
_controller.nextPage(duration: Duration(milliseconds: 200), curve: Curves.bounceIn);
},
),
NavigationFooter(
_controller,
0,
showPrevious: false,
),
],
);
}
}
class LogPage extends StatefulWidget {
PageController _controller;
Setting _setting;
Set _set;
Exercise _exercise;
WorkoutPlan _workoutPlan;
final double _ratioCompleted;
Map<String, int> _exercisePages;
Log _log = Log.empty();
LogPage(
this._controller,
this._setting,
this._set,
this._exercise,
this._workoutPlan,
this._ratioCompleted,
this._exercisePages,
) {
_log.date = DateTime.now();
_log.workoutPlan = _workoutPlan.id!;
_log.exercise = _exercise;
_log.weightUnit = _setting.weightUnitObj;
_log.repetitionUnit = _setting.repetitionUnitObj;
_log.rir = _setting.rir;
}
@override
_LogPageState createState() => _LogPageState();
}
class _LogPageState extends State<LogPage> {
final _form = GlobalKey<FormState>();
String rirValue = Setting.DEFAULT_RIR;
final _repsController = TextEditingController();
final _weightController = TextEditingController();
var _detailed = false;
@override
void initState() {
super.initState();
if (widget._setting.reps != null) {
_repsController.text = widget._setting.reps.toString();
}
if (widget._setting.weight != null) {
_weightController.text = widget._setting.weight.toString();
}
}
Widget getRepsWidget() {
return Row(
children: [
IconButton(
icon: Icon(
Icons.remove,
color: Colors.black,
),
onPressed: () {
try {
int newValue = int.parse(_repsController.text) - 1;
if (newValue > 0) {
_repsController.text = newValue.toString();
}
} on FormatException catch (e) {}
},
),
Expanded(
child: TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).repetitions,
),
enabled: true,
controller: _repsController,
keyboardType: TextInputType.number,
onFieldSubmitted: (_) {},
onSaved: (newValue) {
widget._log.reps = int.parse(newValue!);
},
validator: (value) {
try {
int.parse(value!);
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
},
),
),
IconButton(
icon: Icon(
Icons.add,
color: Colors.black,
),
onPressed: () {
try {
int newValue = int.parse(_repsController.text) + 1;
_repsController.text = newValue.toString();
} on FormatException catch (e) {}
},
),
],
);
}
Widget getWeightWidget() {
var minPlateWeight = 1.25;
return Row(
children: [
IconButton(
icon: Icon(
Icons.remove,
color: Colors.black,
),
onPressed: () {
try {
double newValue = double.parse(_weightController.text) - (2 * minPlateWeight);
if (newValue > 0) {
setState(() {
widget._log.weight = newValue;
_weightController.text = newValue.toString();
});
}
} on FormatException catch (e) {}
},
),
Expanded(
child: TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).weight,
),
controller: _weightController,
keyboardType: TextInputType.number,
onFieldSubmitted: (_) {},
onChanged: (value) {
try {
double.parse(value);
setState(() {
widget._log.weight = double.parse(value);
});
} on FormatException catch (e) {}
},
onSaved: (newValue) {
setState(() {
widget._log.weight = double.parse(newValue!);
});
},
validator: (value) {
try {
double.parse(value!);
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
},
),
),
IconButton(
icon: Icon(
Icons.add,
color: Colors.black,
),
onPressed: () {
try {
double newValue = double.parse(_weightController.text) + (2 * minPlateWeight);
setState(() {
widget._log.weight = newValue;
_weightController.text = newValue.toString();
});
} on FormatException catch (e) {}
},
),
],
);
}
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.singleLogRepTextNoNl),
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(),
],
);
}
Widget getPlates() {
final plates = plateCalculator(
double.parse(_weightController.text),
BAR_WEIGHT,
AVAILABLE_PLATES,
);
final groupedPlates = groupPlates(plates);
return Column(
children: [
Text(
AppLocalizations.of(context).plateCalculator,
style: Theme.of(context).textTheme.headline6,
),
SizedBox(
height: 35,
child: plates.length > 0
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
...groupedPlates.keys
.map(
(key) => Row(
children: [
Text(groupedPlates[key].toString()),
Text('×'),
Container(
decoration: BoxDecoration(
color: wgerPrimaryColorLight,
shape: BoxShape.circle,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: SizedBox(
height: 35,
width: 35,
child: Align(
alignment: Alignment.center,
child: Text(
key.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
),
SizedBox(width: 10),
],
),
)
.toList()
],
)
: MutedText(AppLocalizations.of(context).plateCalculatorNotDivisible),
),
SizedBox(height: 3),
],
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
NavigationHeader(
widget._exercise.name,
widget._controller,
exercisePages: widget._exercisePages,
),
Center(
child: Text(
widget._setting.singleSettingRepText,
style: Theme.of(context).textTheme.headline3,
textAlign: TextAlign.center,
),
),
if (widget._set.comment != '')
Text(
widget._set.comment,
textAlign: TextAlign.center,
),
SizedBox(height: 10),
Expanded(
child: (widget._workoutPlan.filterLogsByExercise(widget._exercise).length > 0)
? getPastLogs()
: Container()),
// Only show calculator for barbell
if (widget._log.exerciseObj.equipment.map((e) => e.id).contains(ID_EQUIPMENT_BARBELL))
getPlates(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Card(
color: wgerBackground,
child: getForm(),
),
),
NavigationFooter(widget._controller, widget._ratioCompleted),
],
);
}
}
class ExerciseOverview extends StatelessWidget {
final PageController _controller;
final Exercise _exercise;
final double _ratioCompleted;
Map<String, int> _exercisePages;
ExerciseOverview(
this._controller,
this._exercise,
this._ratioCompleted,
this._exercisePages,
);
@override
Widget build(BuildContext context) {
return Column(
children: [
NavigationHeader(
_exercise.name,
_controller,
exercisePages: _exercisePages,
),
Divider(),
Expanded(
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 15),
children: [
Text(
_exercise.categoryObj.name,
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
),
..._exercise.equipment
.map((e) => Text(
e.name,
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
))
.toList(),
if (_exercise.images.length > 0)
Container(
width: double.infinity,
height: 200,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
..._exercise.images.map((e) => ExerciseImageWidget(image: e)).toList(),
],
),
),
Html(data: _exercise.description),
],
),
),
NavigationFooter(_controller, _ratioCompleted),
],
);
}
}
class SessionPage extends StatefulWidget {
WorkoutPlan _workoutPlan;
PageController _controller;
TimeOfDay _start;
Map<String, int> _exercisePages;
SessionPage(
this._workoutPlan,
this._controller,
this._start,
this._exercisePages,
);
@override
_SessionPageState createState() => _SessionPageState();
}
class _SessionPageState extends State<SessionPage> {
final _form = GlobalKey<FormState>();
final impressionController = TextEditingController();
final notesController = TextEditingController();
final timeStartController = TextEditingController();
final timeEndController = TextEditingController();
var _session = WorkoutSession();
/// Selected impression: bad, neutral, good
var selectedImpression = [false, true, false];
@override
void initState() {
super.initState();
timeStartController.text = timeToString(widget._start)!;
timeEndController.text = timeToString(TimeOfDay.now())!;
_session.workoutId = widget._workoutPlan.id!;
_session.impression = DEFAULT_IMPRESSION;
_session.date = DateTime.now();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).workoutSession,
widget._controller,
exercisePages: widget._exercisePages,
),
Divider(),
Expanded(child: Container()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Form(
key: _form,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ToggleButtons(
children: <Widget>[
Icon(
Icons.sentiment_very_dissatisfied,
),
Icon(
Icons.sentiment_neutral,
),
Icon(
Icons.sentiment_very_satisfied,
),
],
renderBorder: false,
onPressed: (int index) {
setState(() {
for (int buttonIndex = 0;
buttonIndex < selectedImpression.length;
buttonIndex++) {
_session.impression = index + 1;
if (buttonIndex == index) {
selectedImpression[buttonIndex] = true;
} else {
selectedImpression[buttonIndex] = false;
}
}
});
},
isSelected: selectedImpression,
),
TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).notes,
),
maxLines: 3,
controller: notesController,
keyboardType: TextInputType.multiline,
onFieldSubmitted: (_) {},
onSaved: (newValue) {
_session.notes = newValue!;
},
),
Row(
children: [
Flexible(
child: TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).timeStart,
errorMaxLines: 2,
),
controller: timeStartController,
onFieldSubmitted: (_) {},
onTap: () async {
// Stop keyboard from appearing
FocusScope.of(context).requestFocus(new FocusNode());
// Open time picker
var pickedTime = await showTimePicker(
context: context,
initialTime: widget._start,
);
if (pickedTime != null) {
timeStartController.text = timeToString(pickedTime)!;
}
},
onSaved: (newValue) {
_session.timeStart = stringToTime(newValue);
},
validator: (_) {
TimeOfDay startTime = stringToTime(timeStartController.text);
TimeOfDay endTime = stringToTime(timeEndController.text);
if (startTime.isAfter(endTime)) {
return AppLocalizations.of(context).timeStartAhead;
}
return null;
}),
),
SizedBox(width: 10),
Flexible(
child: TextFormField(
decoration:
InputDecoration(labelText: AppLocalizations.of(context).timeEnd),
controller: timeEndController,
onFieldSubmitted: (_) {},
onTap: () async {
// Stop keyboard from appearing
FocusScope.of(context).requestFocus(new FocusNode());
// Open time picker
var pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
timeEndController.text = timeToString(pickedTime)!;
},
onSaved: (newValue) {
_session.timeEnd = stringToTime(newValue);
},
),
),
],
),
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)
.addSession(_session);
Navigator.of(context).pop();
} on WgerHttpException catch (error) {
showHttpExceptionErrorDialog(error, context);
} catch (error) {
showErrorDialog(error, context);
}
},
),
],
),
),
),
NavigationFooter(
widget._controller,
1,
showNext: false,
),
],
);
}
}
class TimerWidget extends StatefulWidget {
final PageController _controller;
final double _ratioCompleted;
Map<String, int> _exercisePages;
TimerWidget(this._controller, this._ratioCompleted, this._exercisePages);
@override
_TimerWidgetState createState() => _TimerWidgetState();
}
class _TimerWidgetState extends State<TimerWidget> {
// See https://stackoverflow.com/questions/54610121/flutter-countdown-timer
Timer? _timer;
int _seconds = 1;
final _maxSeconds = 600;
DateTime today = new DateTime(2000, 1, 1, 0, 0, 0);
void startTimer() {
setState(() {
_seconds = 0;
});
_timer?.cancel();
const oneSecond = const Duration(seconds: 1);
_timer = new Timer.periodic(
oneSecond,
(Timer timer) {
if (_seconds == _maxSeconds) {
setState(() {
timer.cancel();
});
} else {
setState(() {
_seconds++;
});
}
},
);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
void initState() {
// TODO: implement initState
super.initState();
startTimer();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).pause,
widget._controller,
exercisePages: widget._exercisePages,
),
Expanded(
child: Center(
child: Text(
DateFormat('m:ss').format(today.add(Duration(seconds: _seconds))),
style: Theme.of(context).textTheme.headline1!.copyWith(color: wgerPrimaryColor),
),
),
),
NavigationFooter(widget._controller, widget._ratioCompleted),
],
);
}
}
class NavigationFooter extends StatelessWidget {
final PageController _controller;
final double _ratioCompleted;
final bool showPrevious;
final bool showNext;
NavigationFooter(this._controller, this._ratioCompleted,
{this.showPrevious = true, this.showNext = true});
@override
Widget build(BuildContext context) {
return Row(
children: [
showPrevious
? IconButton(
icon: Icon(Icons.chevron_left),
onPressed: () {
_controller.previousPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
},
)
: SizedBox(width: 48),
Expanded(
child: LinearProgressIndicator(
minHeight: 1.5,
value: _ratioCompleted,
valueColor: AlwaysStoppedAnimation<Color>(wgerPrimaryColor),
),
),
showNext
? IconButton(
icon: Icon(Icons.chevron_right),
onPressed: () {
_controller.nextPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
},
)
: SizedBox(width: 48),
],
);
}
}
class NavigationHeader extends StatelessWidget {
final PageController _controller;
final String _title;
Map<String, int> exercisePages;
NavigationHeader(
this._title,
this._controller, {
required this.exercisePages,
});
Widget getDialog(BuildContext context) {
return AlertDialog(
title: Text(
AppLocalizations.of(context).jumpTo,
textAlign: TextAlign.center,
),
contentPadding: EdgeInsets.zero,
content: SingleChildScrollView(
child: Column(
children: [
...exercisePages.keys.map((e) {
return ListTile(
title: Text(e),
trailing: Icon(Icons.chevron_right),
onTap: () {
_controller.animateToPage(
exercisePages[e]!,
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
Navigator.of(context).pop();
},
);
}).toList(),
],
),
),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.of(context).pop();
},
)
],
);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
icon: Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(
_title,
style: Theme.of(context).textTheme.headline5,
textAlign: TextAlign.center,
),
),
),
IconButton(
icon: Icon(Icons.menu),
onPressed: () {
showDialog(
context: context,
builder: (ctx) => getDialog(context),
);
},
),
],
);
}
}