slrpg-app/lib/src/features/stats/presentation/screens/stats_screen.dart

528 lines
16 KiB
Dart

import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/workout_repository.dart';
import '../../../../shared/domain/entities/exercise.dart';
import '../../../../shared/domain/logic/wendler_calculator.dart';
import '../../domain/entities/stats_data_point.dart';
import '../widgets/progress_chart.dart';
class StatsScreen extends ConsumerStatefulWidget {
const StatsScreen({super.key});
@override
ConsumerState<StatsScreen> createState() => _StatsScreenState();
}
class _StatsScreenState extends ConsumerState<StatsScreen> {
bool _isLoading = false;
String _selectedExercise = 'squat';
String _selectedRange = '3m'; // 1m, 3m, 1y, all
List<StatsDataPoint> _chartData = [];
bool _isChartLoading = true;
@override
void initState() {
super.initState();
_loadStats();
}
Future<void> _loadStats() async {
setState(() => _isChartLoading = true);
try {
final workoutRepo = ref.read(workoutRepositoryProvider);
final userRepo = ref.read(userRepositoryProvider);
final user = await userRepo.getLocalUser();
if (user == null) {
setState(() => _isChartLoading = false);
return;
}
final userId = user.serverId ?? user.id.toString();
final allWorkouts = await workoutRepo.getCompletedWorkouts(userId);
final points = <StatsDataPoint>[];
for (var workout in allWorkouts) {
if (workout.completedAt == null) continue;
final exercisesList = workout.exercises;
double max1RM = 0.0;
double sessionVolume = 0.0;
bool foundExercise = false;
double trainingMax = 0.0;
for (var exDynamic in exercisesList) {
final exJson = exDynamic as Map<String, dynamic>;
final exercise = Exercise.fromJson(exJson);
if (exercise.exerciseId == _selectedExercise) {
foundExercise = true;
for (var set in exercise.sets) {
if (!set.completed || set.repsActual <= 0) continue;
sessionVolume += set.targetWeightTotal * set.repsActual;
final e1rm = WendlerCalculator.calculate1RM(
set.targetWeightTotal, set.repsActual);
if (e1rm > max1RM) {
max1RM = e1rm;
}
if (set.targetPercentage > 0 && trainingMax == 0) {
trainingMax =
set.targetWeightTotal / (set.targetPercentage / 100.0);
}
}
}
}
if (foundExercise && max1RM > 0) {
points.add(StatsDataPoint(
date: workout.completedAt!,
trainingMax: trainingMax,
estimated1RM: max1RM,
totalVolume: sessionVolume,
));
}
}
points.sort((a, b) => a.date.compareTo(b.date));
final now = DateTime.now();
final filteredPoints = points.where((p) {
if (_selectedRange == '1m') {
return p.date.isAfter(now.subtract(const Duration(days: 30)));
} else if (_selectedRange == '3m') {
return p.date.isAfter(now.subtract(const Duration(days: 90)));
} else if (_selectedRange == '1y') {
return p.date.isAfter(now.subtract(const Duration(days: 365)));
}
return true;
}).toList();
if (mounted) {
setState(() {
_chartData = filteredPoints;
_isChartLoading = false;
});
}
} catch (e) {
log('Failed to calculate local stats: $e');
if (mounted) {
setState(() {
_chartData = [];
_isChartLoading = false;
});
}
}
}
void _onFilterChanged(String exercise, String range) {
setState(() {
_selectedExercise = exercise;
_selectedRange = range;
});
_loadStats();
}
Future<void> _handleFinishCycle(CycleCollection currentCycle) async {
setState(() => _isLoading = true);
try {
final cycleRepo = ref.read(cycleRepositoryProvider);
final oldTMs = currentCycle.trainingMaxes;
final newCycle = await cycleRepo.finishCycle();
final newTMs = newCycle.trainingMaxes;
if (mounted) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => _CycleFinishDialog(
oldTMs: oldTMs,
newTMs: newTMs,
newCycleNumber: newCycle.cycleNumber,
),
);
setState(() {});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error finishing cycle: $e'),
backgroundColor: AppTheme.errorColor),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final cycleRepo = ref.watch(cycleRepositoryProvider);
final userRepo = ref.watch(userRepositoryProvider);
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.statsTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/hub'),
),
),
body: FutureBuilder<List<dynamic>>(
future: Future.wait([
cycleRepo.getCurrentCycle(),
userRepo.getLocalUser(),
]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final currentCycle = snapshot.data?[0] as CycleCollection?;
final user = snapshot.data?[1] as UserCollection;
final variants = user.exerciseVariants ?? {};
final pullVariant = variants['pull'] ?? 'pullup';
final pushVariant = variants['push'] ?? 'dip';
String getLabel(String id) {
switch (id) {
case 'squat':
return 'Squat';
case 'pullup':
return 'Pull-up';
case 'row':
return 'Row';
case 'dip':
return 'Dip';
case 'bench':
return 'Bench';
default:
return id;
}
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (currentCycle != null) ...[
_CurrentCycleCard(
cycle: currentCycle,
user: user,
onFinish: _isLoading
? null
: () => _handleFinishCycle(currentCycle),
),
const SizedBox(height: 24),
],
Text(
l10n.statsProgressAnalysis,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: AppTheme.textPrimary),
),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: l10n.exerciseSquat,
isSelected: _selectedExercise == 'squat',
onTap: () => _onFilterChanged('squat', _selectedRange),
),
const SizedBox(width: 8),
_FilterChip(
label: getLabel(pullVariant),
isSelected: _selectedExercise == pullVariant,
onTap: () =>
_onFilterChanged(pullVariant, _selectedRange),
),
const SizedBox(width: 8),
_FilterChip(
label: getLabel(pushVariant),
isSelected: _selectedExercise == pushVariant,
onTap: () =>
_onFilterChanged(pushVariant, _selectedRange),
),
],
),
),
const SizedBox(height: 16),
_isChartLoading
? const SizedBox(
height: 250,
child: Center(child: CircularProgressIndicator()))
: ProgressChart(data: _chartData),
],
),
);
},
),
);
}
}
class _CurrentCycleCard extends StatelessWidget {
final CycleCollection cycle;
final UserCollection user;
final VoidCallback? onFinish;
const _CurrentCycleCard(
{required this.cycle, required this.user, required this.onFinish});
@override
Widget build(BuildContext context) {
final tms = cycle.trainingMaxes;
final variants = user.exerciseVariants ?? {};
final pullVariant = variants['pull'] ?? 'pullup';
final pushVariant = variants['push'] ?? 'dip';
final l10n = AppLocalizations.of(context)!;
String getLabel(String id) {
switch (id) {
case 'squat':
return l10n.exerciseSquat;
case 'pullup':
return l10n.exercisePullup;
case 'row':
return l10n.exerciseRow;
case 'dip':
return l10n.exerciseDip;
case 'bench':
return l10n.exerciseBench;
default:
return id;
}
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.statsCycleTitle(cycle.cycleNumber),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.primaryColor,
fontSize: 24,
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.successColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
l10n.hubActiveYes,
style: TextStyle(
color: AppTheme.successColor,
fontWeight: FontWeight.bold,
fontSize: 12),
),
),
],
),
const Divider(height: 32),
Text(l10n.statsCurrentTM,
style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 16),
_StatRow(
label: l10n.exerciseSquat,
value: '${tms['squat'].toStringAsFixed(2)} kg'),
_StatRow(
label: getLabel(pullVariant),
value: '${tms['pullup'].toStringAsFixed(2)} kg'),
_StatRow(
label: getLabel(pushVariant),
value: '${tms['dip'].toStringAsFixed(2)} kg'),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: onFinish,
icon: const Icon(Icons.upgrade),
label: Text(l10n.statsFinishCycle),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
),
),
),
],
),
),
);
}
}
class _StatRow extends StatelessWidget {
final String label;
final String value;
const _StatRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: Theme.of(context).textTheme.bodyLarge),
Text(value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, color: AppTheme.textSecondary)),
],
),
);
}
}
class _CycleFinishDialog extends StatelessWidget {
final Map<String, dynamic> oldTMs;
final Map<String, dynamic> newTMs;
final int newCycleNumber;
const _CycleFinishDialog({
required this.oldTMs,
required this.newTMs,
required this.newCycleNumber,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l10n.statsCycleFinishedTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.statsCycleFinishedBody,
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Text(l10n.statsTMIncreased,
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_DiffRow(
name: l10n.exerciseSquat,
oldVal: (oldTMs['squat'] as num).toDouble(),
newVal: (newTMs['squat'] as num).toDouble()),
_DiffRow(
name: l10n.exercisePullup,
oldVal: (oldTMs['pullup'] as num).toDouble(),
newVal: (newTMs['pullup'] as num).toDouble()),
_DiffRow(
name: l10n.exerciseDip,
oldVal: (oldTMs['dip'] as num).toDouble(),
newVal: (newTMs['dip'] as num).toDouble()),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.statsEnterNextLevel),
),
],
);
}
}
class _DiffRow extends StatelessWidget {
final String name;
final double oldVal;
final double newVal;
const _DiffRow(
{required this.name, required this.oldVal, required this.newVal});
@override
Widget build(BuildContext context) {
final diff = newVal - oldVal;
final isPositive = diff > 0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(child: Text(name)),
Text('${oldVal.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.grey)),
Text(
newVal.toStringAsFixed(2),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (isPositive)
Text('+${diff.toStringAsFixed(2)}',
style: const TextStyle(
color: AppTheme.successColor, fontWeight: FontWeight.bold))
else
const Text('STALLED',
style: TextStyle(color: AppTheme.secondaryColor, fontSize: 12)),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _FilterChip(
{required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
return ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (_) => onTap(),
selectedColor: AppTheme.primaryColor.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? AppTheme.primaryColor : Colors.grey,
fontWeight: FontWeight.bold,
),
side: BorderSide(
color: isSelected
? AppTheme.primaryColor
: Colors.grey.withValues(alpha: 0.3),
),
);
}
}