528 lines
16 KiB
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),
|
|
),
|
|
);
|
|
}
|
|
}
|