glp-companion/lib/features/exercise/presentation/widgets/log_exercise_dialog.dart

185 lines
6.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/muscle_group.dart';
import '../controllers/exercise_controller.dart';
/// Modal dialog for logging a resistance-training working set.
class LogExerciseDialog extends ConsumerStatefulWidget {
const LogExerciseDialog({super.key});
@override
ConsumerState<LogExerciseDialog> createState() => _LogExerciseDialogState();
}
class _LogExerciseDialogState extends ConsumerState<LogExerciseDialog> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _setsController =
TextEditingController(text: '3');
final TextEditingController _repsController =
TextEditingController(text: '8');
final TextEditingController _weightController = TextEditingController();
MuscleGroup _muscleGroup = MuscleGroup.chest;
bool _isSaving = false;
@override
void dispose() {
_nameController.dispose();
_setsController.dispose();
_repsController.dispose();
_weightController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Log Exercise'),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<MuscleGroup>(
initialValue: _muscleGroup,
decoration: const InputDecoration(
labelText: 'Muscle group',
border: OutlineInputBorder(),
),
items: [
for (final group in MuscleGroup.values)
DropdownMenuItem<MuscleGroup>(
value: group,
child: Text(group.displayName),
),
],
onChanged: (group) => setState(
() => _muscleGroup = group ?? _muscleGroup,
),
),
const SizedBox(height: 12),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Exercise',
hintText: 'e.g. Back Squat',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.words,
validator: (value) => (value == null || value.trim().isEmpty)
? 'Enter an exercise name'
: null,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _setsController,
decoration: const InputDecoration(
labelText: 'Sets',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
validator: _positiveIntValidator,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _repsController,
decoration: const InputDecoration(
labelText: 'Reps',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
validator: _positiveIntValidator,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _weightController,
decoration: const InputDecoration(
labelText: 'Weight (lb)',
hintText: '0',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return null; // bodyweight work
}
final parsed = double.tryParse(value.trim());
if (parsed == null || parsed < 0) return 'Invalid';
return null;
},
),
),
],
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: _isSaving ? null : _save,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Log Set'),
),
],
);
}
String? _positiveIntValidator(String? value) {
final parsed = int.tryParse((value ?? '').trim());
if (parsed == null || parsed <= 0) return 'Must be > 0';
return null;
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _isSaving = true);
final saved =
await ref.read(exerciseControllerProvider.notifier).logExercise(
muscleGroup: _muscleGroup,
exerciseName: _nameController.text,
sets: int.parse(_setsController.text.trim()),
reps: int.parse(_repsController.text.trim()),
weightLbs: _weightController.text.trim().isEmpty
? 0
: double.parse(_weightController.text.trim()),
);
if (!mounted) return;
if (saved) {
Navigator.of(context).pop();
} else {
setState(() => _isSaving = false);
}
}
}