mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
This solves some problems with the keyboard focus changing or the quick-add buttons for reps and weight. Also, add some tests for these.
665 lines
22 KiB
Dart
665 lines
22 KiB
Dart
/*
|
||
* This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||
* Copyright (c) 2020 - 2026 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:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:logging/logging.dart';
|
||
import 'package:provider/provider.dart' as provider;
|
||
import 'package:wger/core/exceptions/http_exception.dart';
|
||
import 'package:wger/helpers/consts.dart';
|
||
import 'package:wger/l10n/generated/app_localizations.dart';
|
||
import 'package:wger/models/workouts/log.dart';
|
||
import 'package:wger/models/workouts/set_config_data.dart';
|
||
import 'package:wger/models/workouts/slot_entry.dart';
|
||
import 'package:wger/providers/gym_log_state.dart';
|
||
import 'package:wger/providers/gym_state.dart';
|
||
import 'package:wger/providers/plate_weights.dart';
|
||
import 'package:wger/providers/routines.dart';
|
||
import 'package:wger/screens/settings_plates_screen.dart';
|
||
import 'package:wger/widgets/core/core.dart';
|
||
import 'package:wger/widgets/core/progress_indicator.dart';
|
||
import 'package:wger/widgets/routines/forms/reps_unit.dart';
|
||
import 'package:wger/widgets/routines/forms/rir.dart';
|
||
import 'package:wger/widgets/routines/forms/weight_unit.dart';
|
||
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
|
||
import 'package:wger/widgets/routines/plate_calculator.dart';
|
||
|
||
class LogPage extends ConsumerWidget {
|
||
final _logger = Logger('LogPage');
|
||
|
||
final PageController _controller;
|
||
LogPage(this._controller);
|
||
final GlobalKey<_LogFormWidgetState> _logFormKey = GlobalKey<_LogFormWidgetState>();
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final theme = Theme.of(context);
|
||
final gymState = ref.watch(gymStateProvider);
|
||
final languageCode = Localizations.localeOf(context).languageCode;
|
||
|
||
final page = gymState.getPageByIndex();
|
||
if (page == null) {
|
||
_logger.info(
|
||
'getPageByIndex for ${gymState.currentPage} returned null, showing empty container.',
|
||
);
|
||
return Container();
|
||
}
|
||
|
||
final slotEntryPage = gymState.getSlotEntryPageByIndex();
|
||
if (slotEntryPage == null) {
|
||
_logger.info(
|
||
'getSlotPageByIndex for ${gymState.currentPage} returned null, showing empty container',
|
||
);
|
||
return Container();
|
||
}
|
||
final setConfigData = slotEntryPage.setConfigData!;
|
||
|
||
final log = ref.watch(gymLogProvider);
|
||
|
||
// Mark done sets
|
||
final decorationStyle = slotEntryPage.logDone
|
||
? TextDecoration.lineThrough
|
||
: TextDecoration.none;
|
||
|
||
return Column(
|
||
children: [
|
||
NavigationHeader(
|
||
log!.exercise.getTranslation(languageCode).name,
|
||
_controller,
|
||
key: const ValueKey('log-page-navigation-header'),
|
||
),
|
||
|
||
Container(
|
||
color: theme.colorScheme.onInverseSurface,
|
||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||
child: Center(
|
||
child: Column(
|
||
children: [
|
||
Column(
|
||
children: [
|
||
Text(
|
||
setConfigData.textRepr,
|
||
textAlign: TextAlign.center,
|
||
style: theme.textTheme.headlineMedium?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
decoration: decorationStyle,
|
||
),
|
||
),
|
||
if (setConfigData.type != SlotEntryType.normal)
|
||
Text(
|
||
setConfigData.type.name.toUpperCase(),
|
||
textAlign: TextAlign.center,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
decoration: decorationStyle,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Text(
|
||
'${slotEntryPage.setIndex + 1} / ${page.slotPages.where((e) => e.type == SlotPageType.log).length}',
|
||
style: theme.textTheme.bodyLarge?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (log.exercise.showPlateCalculator) const LogsPlatesWidget(),
|
||
if (slotEntryPage.setConfigData!.comment.isNotEmpty)
|
||
Text(slotEntryPage.setConfigData!.comment, textAlign: TextAlign.center),
|
||
const SizedBox(height: 10),
|
||
Expanded(
|
||
child: (gymState.routine.filterLogsByExercise(log.exerciseId).isNotEmpty)
|
||
? LogsPastLogsWidget(
|
||
pastLogs: gymState.routine.filterLogsByExercise(log.exerciseId),
|
||
)
|
||
: Container(),
|
||
),
|
||
|
||
Padding(
|
||
padding: const EdgeInsets.all(10),
|
||
child: Card(
|
||
color: Theme.of(context).colorScheme.inversePrimary,
|
||
// color: Theme.of(context).secondaryHeaderColor,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||
child: LogFormWidget(
|
||
controller: _controller,
|
||
configData: setConfigData,
|
||
// log: log!,
|
||
key: _logFormKey,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
NavigationFooter(_controller),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class LogsPlatesWidget extends ConsumerWidget {
|
||
const LogsPlatesWidget({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final plateWeightsState = ref.watch(plateCalculatorProvider);
|
||
|
||
return Container(
|
||
color: Theme.of(context).colorScheme.onInverseSurface,
|
||
child: Column(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () {
|
||
Navigator.of(context).pushNamed(ConfigurePlatesScreen.routeName);
|
||
},
|
||
child: SizedBox(
|
||
child: plateWeightsState.hasPlates
|
||
? Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
...plateWeightsState.calculatePlates.entries.map(
|
||
(entry) => Row(
|
||
children: [
|
||
Text(entry.value.toString()),
|
||
const Text('×'),
|
||
PlateWeight(
|
||
value: entry.key,
|
||
size: 37,
|
||
padding: 2,
|
||
margin: 0,
|
||
color: ref.read(plateCalculatorProvider).getColor(entry.key),
|
||
),
|
||
const SizedBox(width: 10),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: MutedText(
|
||
AppLocalizations.of(context).plateCalculatorNotDivisible,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class LogsRepsWidget extends ConsumerStatefulWidget {
|
||
final num valueChange;
|
||
|
||
const LogsRepsWidget({
|
||
super.key,
|
||
num? valueChange,
|
||
}) : valueChange = valueChange ?? 1;
|
||
|
||
@override
|
||
ConsumerState<LogsRepsWidget> createState() => _LogsRepsWidgetState();
|
||
}
|
||
|
||
class _LogsRepsWidgetState extends ConsumerState<LogsRepsWidget> {
|
||
final _logger = Logger('LogsRepsWidget');
|
||
late TextEditingController _controller;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = TextEditingController();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
|
||
final i18n = AppLocalizations.of(context);
|
||
|
||
final logNotifier = ref.read(gymLogProvider.notifier);
|
||
final log = ref.watch(gymLogProvider);
|
||
final currentReps = log?.repetitions;
|
||
final currentInput = numberFormat.tryParse(_controller.text);
|
||
|
||
// Sync from provider to controller if needed
|
||
if (currentReps != null) {
|
||
// Update if values differ, but allow invalid input while typing (unless empty/initial)
|
||
if (currentInput != currentReps && (currentInput != null || _controller.text.isEmpty)) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
_controller.text = numberFormat.format(currentReps);
|
||
}
|
||
});
|
||
}
|
||
} else if (_controller.text.isNotEmpty) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
_controller.clear();
|
||
}
|
||
});
|
||
}
|
||
|
||
return Row(
|
||
children: [
|
||
// "Quick-remove" button
|
||
IconButton(
|
||
icon: const Icon(Icons.remove, color: Colors.black),
|
||
onPressed: () {
|
||
final base = currentReps ?? 0;
|
||
final newValue = base - widget.valueChange;
|
||
if (newValue >= 0 && log != null) {
|
||
logNotifier.setRepetitions(newValue);
|
||
}
|
||
},
|
||
),
|
||
|
||
// Text field
|
||
Expanded(
|
||
child: TextFormField(
|
||
decoration: InputDecoration(labelText: i18n.repetitions),
|
||
enabled: true,
|
||
controller: _controller,
|
||
keyboardType: textInputTypeDecimal,
|
||
onChanged: (value) {
|
||
try {
|
||
final newValue = numberFormat.parse(value);
|
||
logNotifier.setRepetitions(newValue);
|
||
} on FormatException catch (error) {
|
||
_logger.finer('Error parsing repetitions: $error');
|
||
}
|
||
},
|
||
onSaved: (newValue) {
|
||
if (newValue == null || log == null) {
|
||
return;
|
||
}
|
||
logNotifier.setRepetitions(numberFormat.parse(newValue));
|
||
},
|
||
validator: (value) {
|
||
if (numberFormat.tryParse(value ?? '') == null) {
|
||
return i18n.enterValidNumber;
|
||
}
|
||
return null;
|
||
},
|
||
),
|
||
),
|
||
|
||
// "Quick-add" button
|
||
IconButton(
|
||
icon: const Icon(Icons.add, color: Colors.black),
|
||
onPressed: () {
|
||
final base = currentReps ?? 0;
|
||
final newValue = base + widget.valueChange;
|
||
if (newValue >= 0 && log != null) {
|
||
logNotifier.setRepetitions(newValue);
|
||
}
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class LogsWeightWidget extends ConsumerStatefulWidget {
|
||
final num valueChange;
|
||
|
||
const LogsWeightWidget({
|
||
super.key,
|
||
num? valueChange,
|
||
}) : valueChange = valueChange ?? 1.25;
|
||
|
||
@override
|
||
ConsumerState<LogsWeightWidget> createState() => _LogsWeightWidgetState();
|
||
}
|
||
|
||
class _LogsWeightWidgetState extends ConsumerState<LogsWeightWidget> {
|
||
final _logger = Logger('LogsWeightWidget');
|
||
late TextEditingController _controller;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = TextEditingController();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
|
||
final i18n = AppLocalizations.of(context);
|
||
|
||
final plateProvider = ref.read(plateCalculatorProvider.notifier);
|
||
final logProvider = ref.read(gymLogProvider.notifier);
|
||
final log = ref.watch(gymLogProvider);
|
||
final currentWeight = log?.weight;
|
||
final currentInput = numberFormat.tryParse(_controller.text);
|
||
|
||
// Sync from provider to controller if needed
|
||
if (currentWeight != null) {
|
||
// Update if values differ, but allow invalid input while typing (unless empty/initial)
|
||
if (currentInput != currentWeight && (currentInput != null || _controller.text.isEmpty)) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
_controller.text = numberFormat.format(currentWeight);
|
||
}
|
||
});
|
||
}
|
||
} else if (_controller.text.isNotEmpty) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
_controller.clear();
|
||
}
|
||
});
|
||
}
|
||
|
||
return Row(
|
||
children: [
|
||
IconButton(
|
||
// "Quick-remove" button
|
||
icon: const Icon(Icons.remove, color: Colors.black),
|
||
onPressed: () {
|
||
final base = currentWeight ?? 0;
|
||
final newValue = base - widget.valueChange;
|
||
if (newValue >= 0 && log != null) {
|
||
logProvider.setWeight(newValue);
|
||
}
|
||
},
|
||
),
|
||
|
||
// Text field
|
||
Expanded(
|
||
child: TextFormField(
|
||
controller: _controller,
|
||
decoration: InputDecoration(labelText: i18n.weight),
|
||
keyboardType: textInputTypeDecimal,
|
||
onChanged: (value) {
|
||
try {
|
||
final newValue = numberFormat.parse(value);
|
||
plateProvider.setWeight(newValue);
|
||
logProvider.setWeight(newValue);
|
||
} on FormatException catch (error) {
|
||
_logger.finer('Error parsing weight: $error');
|
||
}
|
||
},
|
||
onSaved: (newValue) {
|
||
if (newValue == null || log == null) {
|
||
return;
|
||
}
|
||
logProvider.setWeight(numberFormat.parse(newValue));
|
||
},
|
||
validator: (value) {
|
||
if (numberFormat.tryParse(value ?? '') == null) {
|
||
return i18n.enterValidNumber;
|
||
}
|
||
return null;
|
||
},
|
||
),
|
||
),
|
||
|
||
// "Quick-add" button
|
||
IconButton(
|
||
icon: const Icon(Icons.add, color: Colors.black),
|
||
onPressed: () {
|
||
final base = currentWeight ?? 0;
|
||
final newValue = base + widget.valueChange;
|
||
if (log != null) {
|
||
logProvider.setWeight(newValue);
|
||
}
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class LogsPastLogsWidget extends ConsumerWidget {
|
||
final List<Log> pastLogs;
|
||
|
||
const LogsPastLogsWidget({
|
||
super.key,
|
||
required this.pastLogs,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final logProvider = ref.read(gymLogProvider.notifier);
|
||
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: ListView(
|
||
children: [
|
||
Text(
|
||
AppLocalizations.of(context).labelWorkoutLogs,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
...pastLogs.map((pastLog) {
|
||
return ListTile(
|
||
key: ValueKey('past-log-${pastLog.id}'),
|
||
title: Text(pastLog.repTextNoNl(context)),
|
||
subtitle: Text(dateFormat.format(pastLog.date)),
|
||
trailing: const Icon(Icons.copy),
|
||
onTap: () {
|
||
logProvider.setLog(pastLog);
|
||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(AppLocalizations.of(context).dataCopied)),
|
||
);
|
||
},
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 40),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class LogFormWidget extends ConsumerStatefulWidget {
|
||
final _logger = Logger('LogFormWidget');
|
||
|
||
final PageController controller;
|
||
final SetConfigData configData;
|
||
|
||
LogFormWidget({
|
||
super.key,
|
||
required this.controller,
|
||
required this.configData,
|
||
});
|
||
|
||
@override
|
||
_LogFormWidgetState createState() => _LogFormWidgetState();
|
||
}
|
||
|
||
class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
|
||
final _form = GlobalKey<FormState>();
|
||
var _detailed = false;
|
||
bool _isSaving = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final i18n = AppLocalizations.of(context);
|
||
final log = ref.watch(gymLogProvider);
|
||
|
||
return Form(
|
||
key: _form,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(
|
||
i18n.newEntry,
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
if (!_detailed)
|
||
Row(
|
||
children: [
|
||
Flexible(
|
||
child: LogsRepsWidget(
|
||
key: const ValueKey('logs-reps-widget'),
|
||
valueChange: widget.configData.repetitionsRounding,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: LogsWeightWidget(
|
||
key: const ValueKey('logs-weight-widget'),
|
||
valueChange: widget.configData.weightRounding,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (_detailed)
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Flexible(
|
||
child: LogsRepsWidget(
|
||
key: const ValueKey('logs-reps-widget'),
|
||
valueChange: widget.configData.repetitionsRounding,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: RepetitionUnitInputWidget(
|
||
key: const ValueKey('repetition-unit-input-widget'),
|
||
log!.repetitionsUnitId,
|
||
onChanged: (v) => {},
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
),
|
||
if (_detailed)
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Flexible(
|
||
child: LogsWeightWidget(
|
||
key: const ValueKey('logs-weight-widget'),
|
||
valueChange: widget.configData.weightRounding,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: WeightUnitInputWidget(
|
||
log!.weightUnitId,
|
||
onChanged: (v) => {},
|
||
key: const ValueKey('weight-unit-input-widget'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
),
|
||
if (_detailed)
|
||
RiRInputWidget(
|
||
key: const ValueKey('rir-input-widget'),
|
||
log!.rir,
|
||
onChanged: (value) {
|
||
log.rir = value == '' ? null : num.parse(value);
|
||
},
|
||
),
|
||
SwitchListTile(
|
||
key: const ValueKey('units-switch'),
|
||
dense: true,
|
||
title: Text(i18n.setUnitsAndRir),
|
||
value: _detailed,
|
||
onChanged: (value) {
|
||
setState(() {
|
||
_detailed = !_detailed;
|
||
});
|
||
},
|
||
),
|
||
FilledButton(
|
||
key: const ValueKey('save-log-button'),
|
||
onPressed: _isSaving
|
||
? null
|
||
: () async {
|
||
final isValid = _form.currentState!.validate();
|
||
if (!isValid) {
|
||
return;
|
||
}
|
||
_isSaving = true;
|
||
_form.currentState!.save();
|
||
|
||
try {
|
||
final gymState = ref.read(gymStateProvider);
|
||
final gymProvider = ref.read(gymStateProvider.notifier);
|
||
|
||
await provider.Provider.of<RoutinesProvider>(
|
||
context,
|
||
listen: false,
|
||
).addLog(log!);
|
||
final page = gymState.getSlotEntryPageByIndex()!;
|
||
gymProvider.markSlotPageAsDone(page.uuid, isDone: true);
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
duration: const Duration(seconds: 2),
|
||
content: Text(
|
||
i18n.successfullySaved,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
widget.controller.nextPage(
|
||
duration: DEFAULT_ANIMATION_DURATION,
|
||
curve: DEFAULT_ANIMATION_CURVE,
|
||
);
|
||
setState(() {
|
||
_isSaving = false;
|
||
});
|
||
} on WgerHttpException {
|
||
setState(() {
|
||
_isSaving = false;
|
||
});
|
||
rethrow;
|
||
} finally {
|
||
setState(() {
|
||
_isSaving = false;
|
||
});
|
||
}
|
||
},
|
||
child: _isSaving ? const FormProgressIndicator() : Text(i18n.save),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|