Convert the reps and weight log widgets back to stateful.

This solves some problems with the keyboard focus changing or the quick-add
buttons for reps and weight. Also, add some tests for these.
This commit is contained in:
Roland Geider
2026-01-29 17:43:31 +01:00
parent 330ad382a4
commit f06a15ebac
2 changed files with 211 additions and 25 deletions

View File

@@ -211,26 +211,61 @@ class LogsPlatesWidget extends ConsumerWidget {
}
}
class LogsRepsWidget extends ConsumerWidget {
final _logger = Logger('LogsRepsWidget');
class LogsRepsWidget extends ConsumerStatefulWidget {
final num valueChange;
LogsRepsWidget({
const LogsRepsWidget({
super.key,
num? valueChange,
}) : valueChange = valueChange ?? 1;
@override
Widget build(BuildContext context, WidgetRef ref) {
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 repText = currentReps != null ? numberFormat.format(currentReps) : '';
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: [
@@ -239,7 +274,7 @@ class LogsRepsWidget extends ConsumerWidget {
icon: const Icon(Icons.remove, color: Colors.black),
onPressed: () {
final base = currentReps ?? 0;
final newValue = base - valueChange;
final newValue = base - widget.valueChange;
if (newValue >= 0 && log != null) {
logNotifier.setRepetitions(newValue);
}
@@ -251,8 +286,7 @@ class LogsRepsWidget extends ConsumerWidget {
child: TextFormField(
decoration: InputDecoration(labelText: i18n.repetitions),
enabled: true,
key: const ValueKey('reps-field'),
initialValue: repText,
controller: _controller,
keyboardType: textInputTypeDecimal,
onChanged: (value) {
try {
@@ -282,7 +316,7 @@ class LogsRepsWidget extends ConsumerWidget {
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
final base = currentReps ?? 0;
final newValue = base + valueChange;
final newValue = base + widget.valueChange;
if (newValue >= 0 && log != null) {
logNotifier.setRepetitions(newValue);
}
@@ -293,27 +327,62 @@ class LogsRepsWidget extends ConsumerWidget {
}
}
class LogsWeightWidget extends ConsumerWidget {
final _logger = Logger('LogsWeightWidget');
class LogsWeightWidget extends ConsumerStatefulWidget {
final num valueChange;
LogsWeightWidget({
const LogsWeightWidget({
super.key,
num? valueChange,
}) : valueChange = valueChange ?? 1.25;
@override
Widget build(BuildContext context, WidgetRef ref) {
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 weightText = currentWeight != null ? numberFormat.format(currentWeight) : '';
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: [
@@ -322,7 +391,7 @@ class LogsWeightWidget extends ConsumerWidget {
icon: const Icon(Icons.remove, color: Colors.black),
onPressed: () {
final base = currentWeight ?? 0;
final newValue = base - valueChange;
final newValue = base - widget.valueChange;
if (newValue >= 0 && log != null) {
logProvider.setWeight(newValue);
}
@@ -332,9 +401,8 @@ class LogsWeightWidget extends ConsumerWidget {
// Text field
Expanded(
child: TextFormField(
key: const ValueKey('weight-field'),
controller: _controller,
decoration: InputDecoration(labelText: i18n.weight),
initialValue: weightText,
keyboardType: textInputTypeDecimal,
onChanged: (value) {
try {
@@ -365,7 +433,7 @@ class LogsWeightWidget extends ConsumerWidget {
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
final base = currentWeight ?? 0;
final newValue = base + valueChange;
final newValue = base + widget.valueChange;
if (log != null) {
logProvider.setWeight(newValue);
}
@@ -408,9 +476,7 @@ class LogsPastLogsWidget extends ConsumerWidget {
logProvider.setLog(pastLog);
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).dataCopied),
),
SnackBar(content: Text(AppLocalizations.of(context).dataCopied)),
);
},
contentPadding: const EdgeInsets.symmetric(horizontal: 40),

View File

@@ -237,5 +237,125 @@ void main() {
expect(capturedLog!.routineId, equals(notifier.state.routine.id));
expect(capturedLog!.iteration, equals(notifier.state.iteration));
});
testWidgets('LogsRepsWidget quick buttons update values', (tester) async {
// Arrange
final notifier = container.read(gymStateProvider.notifier);
final routine = testdata.getTestRoutine();
routine.dayDataGym[0].slots[0].setConfigs[0].repetitions = 0;
notifier.state = notifier.state.copyWith(
dayId: routine.days.first.id,
routine: routine,
iteration: 1,
);
notifier.calculatePages();
notifier.setCurrentPage(2);
notifier.state = notifier.state.copyWith(currentPage: 2);
final mockRoutines = MockRoutinesProvider();
await pumpLogPage(tester, routinesProvider: mockRoutines);
// Act
final repsWidgetFinder = find.byKey(const ValueKey('logs-reps-widget'));
expect(repsWidgetFinder, findsOneWidget);
final addBtn = find.descendant(
of: repsWidgetFinder,
matching: find.byIcon(Icons.add),
);
final removeBtn = find.descendant(
of: repsWidgetFinder,
matching: find.byIcon(Icons.remove),
);
// Assert
// Increment 0 -> 1
await tester.tap(addBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(
find.descendant(of: repsWidgetFinder, matching: find.text('1')),
findsOneWidget,
);
// Increment 1 -> 2
await tester.tap(addBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(
find.descendant(of: repsWidgetFinder, matching: find.text('2')),
findsOneWidget,
);
// Decrement 2 -> 1
await tester.tap(removeBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(
find.descendant(of: repsWidgetFinder, matching: find.text('1')),
findsOneWidget,
);
});
testWidgets('LogsWeightWidget quick buttons update values', (tester) async {
// Arrange
final notifier = container.read(gymStateProvider.notifier);
final routine = testdata.getTestRoutine();
routine.dayDataGym[0].slots[0].setConfigs[0].weight = 0;
notifier.state = notifier.state.copyWith(
dayId: routine.days.first.id,
routine: routine,
iteration: 1,
);
notifier.calculatePages();
notifier.setCurrentPage(2);
notifier.state = notifier.state.copyWith(currentPage: 2);
final mockRoutines = MockRoutinesProvider();
await pumpLogPage(tester, routinesProvider: mockRoutines);
// Act
final weightWidgetFinder = find.byKey(const ValueKey('logs-weight-widget'));
expect(weightWidgetFinder, findsOneWidget);
final addBtn = find.descendant(
of: weightWidgetFinder,
matching: find.byIcon(Icons.add),
);
final removeBtn = find.descendant(
of: weightWidgetFinder,
matching: find.byIcon(Icons.remove),
);
// Assert
// Increment 0 -> 1.25
await tester.tap(addBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(
find.descendant(of: weightWidgetFinder, matching: find.text('1.25')),
findsOneWidget,
);
// Increment 1.25 -> 2.5
await tester.tap(addBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(
find.descendant(of: weightWidgetFinder, matching: find.text('2.5')),
findsOneWidget,
);
// Decrement 2.5 -> 1.25
await tester.tap(removeBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(
find.descendant(of: weightWidgetFinder, matching: find.text('1.25')),
findsOneWidget,
);
});
});
}