slrpg-app/lib/src/features/onboarding/presentation/screens/strength_test_screen.dart

520 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/domain/logic/wendler_calculator.dart';
import 'bodyweight_input_screen.dart';
class StrengthTestScreen extends ConsumerStatefulWidget {
const StrengthTestScreen({super.key});
@override
ConsumerState<StrengthTestScreen> createState() => _StrengthTestScreenState();
}
class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
final _formKey = GlobalKey<FormState>();
final _squatWeightController = TextEditingController(text: '60');
final _squatRepsController = TextEditingController(text: '5');
bool _canDoPullup = true;
final _pullWeightController = TextEditingController(text: '0');
final _pullRepsController = TextEditingController(text: '5');
bool _canDoDip = true;
final _dipWeightController = TextEditingController(text: '0');
final _benchWeightController = TextEditingController(text: '40');
final _pushRepsController = TextEditingController(text: '5');
Map<String, double> _calculated1RMs = {};
Map<String, double> _calculatedTMs = {};
@override
void initState() {
super.initState();
_calculateAll();
}
@override
void dispose() {
_squatWeightController.dispose();
_squatRepsController.dispose();
_pullWeightController.dispose();
_pullRepsController.dispose();
_dipWeightController.dispose();
_pushRepsController.dispose();
super.dispose();
}
void _calculateAll() {
final bodyweight = ref.read(onboardingDataProvider)['bodyweight'] ?? 80.0;
final squatWeight = double.tryParse(_squatWeightController.text) ?? 0;
final squatReps = int.tryParse(_squatRepsController.text) ?? 1;
final squat1RM = WendlerCalculator.calculate1RM(squatWeight, squatReps);
final squatTM = WendlerCalculator.calculateTrainingMax(squat1RM);
double pull1RM = 0.0;
if (_canDoPullup) {
final added = double.tryParse(_pullWeightController.text) ?? 0;
final reps = int.tryParse(_pullRepsController.text) ?? 1;
pull1RM = WendlerCalculator.calculate1RM(bodyweight + added, reps);
} else {
final weight = double.tryParse(_pullWeightController.text) ?? 0;
final reps = int.tryParse(_pullRepsController.text) ?? 1;
pull1RM = WendlerCalculator.calculate1RM(weight, reps);
}
final pullTM = WendlerCalculator.calculateTrainingMax(pull1RM);
double push1RM = 0.0;
if (_canDoDip) {
final added = double.tryParse(_dipWeightController.text) ?? 0;
final reps = int.tryParse(_pushRepsController.text) ?? 1;
push1RM = WendlerCalculator.calculate1RM(bodyweight + added, reps);
} else {
final weight = double.tryParse(_benchWeightController.text) ?? 0;
final reps = int.tryParse(_pushRepsController.text) ?? 1;
push1RM = WendlerCalculator.calculate1RM(weight, reps);
}
final pushTM = WendlerCalculator.calculateTrainingMax(push1RM);
setState(() {
_calculated1RMs = {
'squat': squat1RM,
'pullup': pull1RM,
'dip': push1RM,
};
_calculatedTMs = {
'squat': squatTM,
'pullup': pullTM,
'dip': pushTM,
};
});
}
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
final variants = <String, String>{
'pull': _canDoPullup ? 'pullup' : 'row',
'push': _canDoDip ? 'dip' : 'bench',
};
ref.read(onboardingDataProvider.notifier).update((state) => {
...state,
'training_maxes': _calculatedTMs,
'exercise_variants': variants,
});
context.go('/onboarding/inventory');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Strength Test'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/onboarding/bodyweight'),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LinearProgressIndicator(
value: 0.5,
backgroundColor: AppTheme.xpBarBackground,
color: AppTheme.primaryColor,
),
const SizedBox(height: 32),
Text(
'Combat Calibration',
style: Theme.of(context).textTheme.displayMedium,
),
const SizedBox(height: 8),
Text(
'We need to assess your current power level to assign the correct monsters.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
_ExerciseCard(
title: 'Leg Strength',
exerciseName: 'Back Squat',
icon: Icons.accessibility_new,
weightController: _squatWeightController,
repsController: _squatRepsController,
isBodyweight: false,
onChanged: _calculateAll,
result1RM: _calculated1RMs['squat'] ?? 0,
resultTM: _calculatedTMs['squat'] ?? 0,
),
const SizedBox(height: 16),
_AdaptiveExerciseCard(
slotTitle: 'Pull Strength',
primaryName: 'Weighted Pull-up',
secondaryName: 'Pendlay Row',
icon: Icons.north,
isCapable: _canDoPullup,
onToggleCapability: (val) {
setState(() {
_canDoPullup = val;
_pullWeightController.text = '0';
_pullRepsController.text = '5';
_calculateAll();
});
},
weightController: _pullWeightController,
repsController: _pullRepsController,
weightLabel:
_canDoPullup ? 'Add. Weight (kg)' : 'Row Weight (kg)',
repsLabel: _canDoPullup ? 'Reps' : '5RM Reps (usually 5)',
showResults: _canDoPullup || true,
result1RM: _calculated1RMs['pullup'] ?? 0,
resultTM: _calculatedTMs['pullup'] ?? 0,
onChanged: _calculateAll,
),
const SizedBox(height: 16),
_AdaptiveExerciseCard(
slotTitle: 'Push Strength',
primaryName: 'Weighted Dip',
secondaryName: 'Bench Press',
icon: Icons.south,
isCapable: _canDoDip,
onToggleCapability: (val) {
setState(() {
_canDoDip = val;
_dipWeightController.text = '0';
_pushRepsController.text = '5';
_calculateAll();
});
},
weightController:
_canDoDip ? _dipWeightController : _benchWeightController,
repsController: _pushRepsController,
weightLabel: _canDoDip ? 'Add. Weight (kg)' : 'Weight (kg)',
repsLabel: 'Reps',
showWeightInput: true,
showResults: true,
result1RM: _calculated1RMs['dip'] ?? 0,
resultTM: _calculatedTMs['dip'] ?? 0,
onChanged: _calculateAll,
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline,
color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(
child: Text(
'Your "Training Max" (TM) is your base combat power (90% of 1RM). For bodyweight exercises, we adjust the strategy.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _handleContinue,
child: const Text('CONTINUE'),
),
],
),
),
),
),
);
}
}
class _ExerciseCard extends StatelessWidget {
final String title;
final String exerciseName;
final IconData icon;
final TextEditingController weightController;
final TextEditingController repsController;
final bool isBodyweight;
final double result1RM;
final double resultTM;
final VoidCallback onChanged;
const _ExerciseCard({
required this.title,
required this.exerciseName,
required this.icon,
required this.weightController,
required this.repsController,
required this.isBodyweight,
required this.result1RM,
required this.resultTM,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title.toUpperCase(),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: AppTheme.primaryColor),
),
const SizedBox(width: 12),
Text(exerciseName,
style: Theme.of(context).textTheme.titleLarge),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: weightController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
decoration: InputDecoration(
labelText:
isBodyweight ? 'Add. Weight (kg)' : 'Weight (kg)',
isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: repsController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration:
const InputDecoration(labelText: 'Reps', isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
],
),
const SizedBox(height: 16),
_ResultBox(rm: result1RM, tm: resultTM),
],
),
),
);
}
}
class _AdaptiveExerciseCard extends StatelessWidget {
final String slotTitle;
final String primaryName;
final String secondaryName;
final IconData icon;
final bool isCapable;
final ValueChanged<bool> onToggleCapability;
final TextEditingController weightController;
final TextEditingController repsController;
final String weightLabel;
final String repsLabel;
final bool showWeightInput;
final bool showResults;
final double result1RM;
final double resultTM;
final VoidCallback onChanged;
const _AdaptiveExerciseCard({
required this.slotTitle,
required this.primaryName,
required this.secondaryName,
required this.icon,
required this.isCapable,
required this.onToggleCapability,
required this.weightController,
required this.repsController,
required this.weightLabel,
required this.repsLabel,
this.showWeightInput = true,
this.showResults = true,
required this.result1RM,
required this.resultTM,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(slotTitle.toUpperCase(),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold)),
Row(
children: [
Text('Can do 1 rep?',
style: TextStyle(
fontSize: 12,
color: isCapable
? AppTheme.successColor
: Colors.grey)),
Switch(
value: isCapable,
activeColor: AppTheme.successColor,
onChanged: onToggleCapability,
),
],
),
],
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: AppTheme.primaryColor),
),
const SizedBox(width: 12),
Text(isCapable ? primaryName : secondaryName,
style: Theme.of(context).textTheme.titleLarge),
],
),
if (!isCapable) ...[
const SizedBox(height: 8),
Text(
'Adjusted Strategy: ${isCapable ? "Wendler 5/3/1" : "Linear Progression (3x5)"}',
style: const TextStyle(
color: AppTheme.secondaryColor,
fontSize: 12,
fontStyle: FontStyle.italic),
),
],
const SizedBox(height: 16),
Row(
children: [
if (showWeightInput)
Expanded(
flex: 2,
child: TextFormField(
controller: weightController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
decoration: InputDecoration(
labelText: weightLabel, isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
)
else
const Spacer(flex: 2),
if (showWeightInput) const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: repsController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration:
InputDecoration(labelText: repsLabel, isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
],
),
if (showResults) ...[
const SizedBox(height: 16),
_ResultBox(rm: result1RM, tm: resultTM),
],
],
),
),
);
}
}
class _ResultBox extends StatelessWidget {
final double rm;
final double tm;
const _ResultBox({required this.rm, required this.tm});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceColor, borderRadius: BorderRadius.circular(8)),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Est. 1RM'),
Text('${rm.toStringAsFixed(1)} kg',
style: Theme.of(context).textTheme.bodyLarge),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Training Max (90%)'),
Text('${tm.toStringAsFixed(1)} kg',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold)),
],
),
],
),
);
}
}