1060 lines
33 KiB
Dart
1060 lines
33 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../application/application.dart';
|
|
import 'history_screen.dart';
|
|
import 'theme.dart';
|
|
|
|
final class ProgressionScreen extends StatefulWidget {
|
|
const ProgressionScreen({
|
|
required this.progressionStatsUseCase,
|
|
required this.historyUseCases,
|
|
required this.workoutTemplateUseCases,
|
|
required this.activeUseCases,
|
|
required this.closeUseCase,
|
|
this.mediaUseCases,
|
|
this.stepUseCases,
|
|
this.performanceReferenceUseCase,
|
|
this.watchAlertPublisher,
|
|
super.key,
|
|
});
|
|
|
|
final ProgressionStatsUseCase progressionStatsUseCase;
|
|
final WorkoutHistoryUseCases historyUseCases;
|
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
|
final ActiveWorkoutSessionUseCases activeUseCases;
|
|
final ActiveExerciseStepUseCases? stepUseCases;
|
|
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
|
final CloseWorkoutSessionUseCase closeUseCase;
|
|
final MediaUseCases? mediaUseCases;
|
|
final WatchAlertPublisher? watchAlertPublisher;
|
|
|
|
@override
|
|
State<ProgressionScreen> createState() => _ProgressionScreenState();
|
|
}
|
|
|
|
final class _ProgressionScreenState extends State<ProgressionScreen> {
|
|
var _period = ProgressionPeriod.fourWeeks;
|
|
late Future<ProgressionOverview> _overview;
|
|
String? _selectedExerciseKey;
|
|
ProgressionMeasure? _selectedMeasure;
|
|
Future<ProgressionExerciseSeries>? _series;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_overview = _loadOverview();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Progression')),
|
|
body: FutureBuilder<ProgressionOverview>(
|
|
future: _overview,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError) {
|
|
return _CenteredProgressionMessage(
|
|
title: 'Progression indisponible',
|
|
message: snapshot.error.toString(),
|
|
);
|
|
}
|
|
final overview = snapshot.data!;
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_PeriodSelector(
|
|
period: _period,
|
|
onChanged: (period) => _changePeriod(period),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (!overview.hasAnyCompletedHistory)
|
|
_GlobalEmptyState(onOpenHistory: _openHistoryList)
|
|
else ...[
|
|
_VolumeSection(overview: overview),
|
|
const SizedBox(height: 12),
|
|
_RegularitySection(overview: overview),
|
|
const SizedBox(height: 12),
|
|
_ExerciseProgressionSection(
|
|
overview: overview,
|
|
selectedExerciseKey: _selectedExerciseKey,
|
|
selectedMeasure: _selectedMeasure,
|
|
series: _series,
|
|
onExerciseChanged: (exercise) =>
|
|
_selectExercise(overview, exercise),
|
|
onMeasureChanged: (measure) =>
|
|
_selectMeasure(overview, measure),
|
|
onPointTap: _showPointDetail,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<ProgressionOverview> _loadOverview() async {
|
|
final overview = await widget.progressionStatsUseCase.getOverview(_period);
|
|
_syncSelection(overview);
|
|
return overview;
|
|
}
|
|
|
|
void _changePeriod(ProgressionPeriod period) {
|
|
if (period == _period) return;
|
|
setState(() {
|
|
_period = period;
|
|
_selectedExerciseKey = null;
|
|
_selectedMeasure = null;
|
|
_series = null;
|
|
_overview = _loadOverview();
|
|
});
|
|
}
|
|
|
|
void _syncSelection(ProgressionOverview overview) {
|
|
if (overview.exerciseOptions.isEmpty) {
|
|
_selectedExerciseKey = null;
|
|
_selectedMeasure = null;
|
|
_series = null;
|
|
return;
|
|
}
|
|
final exercise = overview.exerciseOptions.firstWhere(
|
|
(option) => option.exerciseKey == _selectedExerciseKey,
|
|
orElse: () => overview.exerciseOptions.first,
|
|
);
|
|
_selectedExerciseKey = exercise.exerciseKey;
|
|
_selectedMeasure = _resolveMeasure(exercise, _selectedMeasure);
|
|
final measure = _selectedMeasure;
|
|
if (measure == null) {
|
|
_series = null;
|
|
return;
|
|
}
|
|
_series = widget.progressionStatsUseCase.getExerciseSeries(
|
|
period: _period,
|
|
exerciseKey: exercise.exerciseKey,
|
|
measure: measure,
|
|
);
|
|
}
|
|
|
|
void _selectExercise(
|
|
ProgressionOverview overview,
|
|
ProgressionExerciseOption exercise,
|
|
) {
|
|
setState(() {
|
|
_selectedExerciseKey = exercise.exerciseKey;
|
|
_selectedMeasure = _resolveMeasure(exercise, null);
|
|
final measure = _selectedMeasure;
|
|
_series = measure == null
|
|
? null
|
|
: widget.progressionStatsUseCase.getExerciseSeries(
|
|
period: _period,
|
|
exerciseKey: exercise.exerciseKey,
|
|
measure: measure,
|
|
);
|
|
});
|
|
}
|
|
|
|
void _selectMeasure(
|
|
ProgressionOverview overview,
|
|
ProgressionMeasure measure,
|
|
) {
|
|
final exercise = _selectedExercise(overview);
|
|
if (exercise == null) return;
|
|
setState(() {
|
|
_selectedMeasure = measure;
|
|
_series = widget.progressionStatsUseCase.getExerciseSeries(
|
|
period: _period,
|
|
exerciseKey: exercise.exerciseKey,
|
|
measure: measure,
|
|
);
|
|
});
|
|
}
|
|
|
|
ProgressionMeasure? _resolveMeasure(
|
|
ProgressionExerciseOption exercise,
|
|
ProgressionMeasure? preferred,
|
|
) {
|
|
final options = exercise.measures;
|
|
if (options.isEmpty) return null;
|
|
if (preferred != null &&
|
|
options.any((option) => option.measure == preferred)) {
|
|
return preferred;
|
|
}
|
|
return options.first.measure;
|
|
}
|
|
|
|
ProgressionExerciseOption? _selectedExercise(ProgressionOverview overview) {
|
|
final selectedKey = _selectedExerciseKey;
|
|
if (selectedKey == null) return null;
|
|
for (final option in overview.exerciseOptions) {
|
|
if (option.exerciseKey == selectedKey) {
|
|
return option;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<void> _showPointDetail(ProgressionExerciseSeries series, int index) {
|
|
final point = series.points[index];
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (context) {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_formatDate(point.startedAt),
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text('Séance : ${point.workoutNameSnapshot}'),
|
|
const SizedBox(height: 8),
|
|
Text(_pointDetailLabel(series, point)),
|
|
const SizedBox(height: 16),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton(
|
|
onPressed: () => _openHistoryDetailFromPoint(
|
|
context,
|
|
point.workoutHistoryId,
|
|
),
|
|
child: const Text('Voir la séance'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _openHistoryList() {
|
|
return Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => HistoryListScreen(
|
|
historyUseCases: widget.historyUseCases,
|
|
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
|
activeUseCases: widget.activeUseCases,
|
|
stepUseCases: widget.stepUseCases,
|
|
performanceReferenceUseCase: widget.performanceReferenceUseCase,
|
|
closeUseCase: widget.closeUseCase,
|
|
mediaUseCases: widget.mediaUseCases,
|
|
watchAlertPublisher: widget.watchAlertPublisher,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openHistoryDetailFromPoint(
|
|
BuildContext bottomSheetContext,
|
|
String workoutHistoryId,
|
|
) async {
|
|
final navigator = Navigator.of(context);
|
|
Navigator.of(bottomSheetContext).pop();
|
|
final history = await widget.historyUseCases.findById(workoutHistoryId);
|
|
if (history == null) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text("Cette séance n'est plus disponible.")),
|
|
);
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
await navigator.push(
|
|
MaterialPageRoute(
|
|
builder: (context) => HistoryDetailScreen(
|
|
history: history,
|
|
historyUseCases: widget.historyUseCases,
|
|
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
|
activeUseCases: widget.activeUseCases,
|
|
stepUseCases: widget.stepUseCases,
|
|
performanceReferenceUseCase: widget.performanceReferenceUseCase,
|
|
closeUseCase: widget.closeUseCase,
|
|
mediaUseCases: widget.mediaUseCases,
|
|
watchAlertPublisher: widget.watchAlertPublisher,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _PeriodSelector extends StatelessWidget {
|
|
const _PeriodSelector({required this.period, required this.onChanged});
|
|
|
|
final ProgressionPeriod period;
|
|
final ValueChanged<ProgressionPeriod> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SegmentedButton<ProgressionPeriod>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: ProgressionPeriod.fourWeeks,
|
|
label: Text('4 semaines'),
|
|
),
|
|
ButtonSegment(
|
|
value: ProgressionPeriod.threeMonths,
|
|
label: Text('3 mois'),
|
|
),
|
|
ButtonSegment(value: ProgressionPeriod.all, label: Text('Tout')),
|
|
],
|
|
selected: {period},
|
|
onSelectionChanged: (selection) => onChanged(selection.first),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _VolumeSection extends StatelessWidget {
|
|
const _VolumeSection({required this.overview});
|
|
|
|
final ProgressionOverview overview;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CourtBlazerAccentPanel(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SectionTitle('Volume'),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _MetricTile(
|
|
label: 'Séances',
|
|
value: overview.completedSessionCount.toString(),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _MetricTile(
|
|
label: 'Temps total',
|
|
value: _formatVolumeDuration(overview.totalActiveMs),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (overview.rangeLabelKind ==
|
|
ProgressionRangeLabelKind.sinceFirstSession) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Depuis ta première séance',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _RegularitySection extends StatelessWidget {
|
|
const _RegularitySection({required this.overview});
|
|
|
|
final ProgressionOverview overview;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final text = overview.totalWeekCount == null
|
|
? '${overview.activeWeekCount} semaines actives au total'
|
|
: overview.activeWeekCount == 1
|
|
? '1 semaine active'
|
|
: '${overview.activeWeekCount} / ${overview.totalWeekCount} semaines actives';
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SectionTitle('Régularité'),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
'Rythme récent',
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(text, style: AppTextStyles.scoreNumber(context)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Une semaine active contient au moins une séance terminée.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _ExerciseProgressionSection extends StatelessWidget {
|
|
const _ExerciseProgressionSection({
|
|
required this.overview,
|
|
required this.selectedExerciseKey,
|
|
required this.selectedMeasure,
|
|
required this.series,
|
|
required this.onExerciseChanged,
|
|
required this.onMeasureChanged,
|
|
required this.onPointTap,
|
|
});
|
|
|
|
final ProgressionOverview overview;
|
|
final String? selectedExerciseKey;
|
|
final ProgressionMeasure? selectedMeasure;
|
|
final Future<ProgressionExerciseSeries>? series;
|
|
final ValueChanged<ProgressionExerciseOption> onExerciseChanged;
|
|
final ValueChanged<ProgressionMeasure> onMeasureChanged;
|
|
final void Function(ProgressionExerciseSeries series, int index) onPointTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final exercise = _selectedExercise;
|
|
return CourtBlazerAccentPanel(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SectionTitle('Par exercice'),
|
|
const SizedBox(height: 12),
|
|
if (overview.exerciseOptions.isEmpty)
|
|
const _InlineProgressionMessage(
|
|
title: 'Pas encore de mesure exploitable',
|
|
message:
|
|
'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.',
|
|
)
|
|
else ...[
|
|
DropdownButtonFormField<String>(
|
|
initialValue: exercise?.exerciseKey,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Choisir un exercice',
|
|
),
|
|
items: [
|
|
for (final option in overview.exerciseOptions)
|
|
DropdownMenuItem(
|
|
value: option.exerciseKey,
|
|
child: _ExerciseOptionLabel(option: option),
|
|
),
|
|
],
|
|
onChanged: (value) {
|
|
final selected = overview.exerciseOptions.firstWhere(
|
|
(option) => option.exerciseKey == value,
|
|
);
|
|
onExerciseChanged(selected);
|
|
},
|
|
),
|
|
if (exercise != null) ...[
|
|
const SizedBox(height: 12),
|
|
_MeasureSelector(
|
|
exercise: exercise,
|
|
selectedMeasure: selectedMeasure,
|
|
onChanged: onMeasureChanged,
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (series == null)
|
|
const _InlineProgressionMessage(
|
|
title: 'Pas encore de mesure exploitable',
|
|
message:
|
|
'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.',
|
|
)
|
|
else
|
|
FutureBuilder<ProgressionExerciseSeries>(
|
|
future: series,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const SizedBox(
|
|
height: 220,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
if (snapshot.hasError) {
|
|
return _InlineProgressionMessage(
|
|
title: 'Progression indisponible',
|
|
message: snapshot.error.toString(),
|
|
);
|
|
}
|
|
return _SeriesBody(
|
|
series: snapshot.data!,
|
|
onPointTap: onPointTap,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
ProgressionExerciseOption? get _selectedExercise {
|
|
final key = selectedExerciseKey;
|
|
if (key == null) return null;
|
|
for (final option in overview.exerciseOptions) {
|
|
if (option.exerciseKey == key) return option;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
final class _MeasureSelector extends StatelessWidget {
|
|
const _MeasureSelector({
|
|
required this.exercise,
|
|
required this.selectedMeasure,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final ProgressionExerciseOption exercise;
|
|
final ProgressionMeasure? selectedMeasure;
|
|
final ValueChanged<ProgressionMeasure> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final options = exercise.measures;
|
|
if (options.length <= 1) {
|
|
final option = options.firstOrNull;
|
|
if (option == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(option.label, style: Theme.of(context).textTheme.labelLarge),
|
|
_MeasureHint(option: option),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SegmentedButton<ProgressionMeasure>(
|
|
segments: [
|
|
for (final option in options)
|
|
ButtonSegment(value: option.measure, label: Text(option.label)),
|
|
],
|
|
selected: {selectedMeasure ?? options.first.measure},
|
|
onSelectionChanged: (selection) => onChanged(selection.first),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_MeasureHint(
|
|
option: options.firstWhere(
|
|
(option) =>
|
|
option.measure == (selectedMeasure ?? options.first.measure),
|
|
orElse: () => options.first,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _MeasureHint extends StatelessWidget {
|
|
const _MeasureHint({required this.option});
|
|
|
|
final ProgressionMeasureOption option;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!option.lowerIsBetter) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Text(
|
|
'Plus bas = mieux',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _SeriesBody extends StatelessWidget {
|
|
const _SeriesBody({required this.series, required this.onPointTap});
|
|
|
|
final ProgressionExerciseSeries series;
|
|
final void Function(ProgressionExerciseSeries series, int index) onPointTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final stateMessage = _stateMessage(series.state);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (stateMessage != null) ...[
|
|
_InlineProgressionMessage(
|
|
title: stateMessage.$1,
|
|
message: stateMessage.$2,
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
if (series.state != ProgressionSeriesState.noGraphableMeasure &&
|
|
series.state != ProgressionSeriesState.textScoreOnly) ...[
|
|
_ProgressionChart(series: series, onPointTap: onPointTap),
|
|
const SizedBox(height: 12),
|
|
_SeriesSummaryView(series: series),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _ProgressionChart extends StatelessWidget {
|
|
const _ProgressionChart({required this.series, required this.onPointTap});
|
|
|
|
final ProgressionExerciseSeries series;
|
|
final void Function(ProgressionExerciseSeries series, int index) onPointTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = courtBlazerTokensOf(context);
|
|
return SizedBox(
|
|
height: 220,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return Stack(
|
|
children: [
|
|
CustomPaint(
|
|
size: Size(constraints.maxWidth, constraints.maxHeight),
|
|
painter: _ProgressionChartPainter(
|
|
series: series,
|
|
theme: Theme.of(context),
|
|
),
|
|
),
|
|
for (var index = 0; index < series.points.length; index++)
|
|
Positioned.fromRect(
|
|
rect: _pointHitRect(
|
|
constraints.biggest,
|
|
series.points,
|
|
index,
|
|
),
|
|
child: Tooltip(
|
|
message: _formatDate(series.points[index].startedAt),
|
|
child: InkWell(
|
|
key: ValueKey('progression-point-$index'),
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: () => onPointTap(series, index),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: tokens.border),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _ProgressionChartPainter extends CustomPainter {
|
|
const _ProgressionChartPainter({required this.series, required this.theme});
|
|
|
|
final ProgressionExerciseSeries series;
|
|
final ThemeData theme;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final tokens =
|
|
theme.extension<CourtBlazerTokens>() ??
|
|
CourtBlazerTokens(
|
|
accent: theme.colorScheme.secondary,
|
|
border: theme.dividerColor,
|
|
mutedText: theme.colorScheme.onSurfaceVariant,
|
|
success: Colors.green,
|
|
);
|
|
final chartRect = Rect.fromLTWH(8, 10, size.width - 16, size.height - 28);
|
|
final gridPaint = Paint()
|
|
..color = tokens.border
|
|
..strokeWidth = 1;
|
|
for (var index = 0; index < 4; index++) {
|
|
final y = chartRect.top + chartRect.height * index / 3;
|
|
canvas.drawLine(
|
|
Offset(chartRect.left, y),
|
|
Offset(chartRect.right, y),
|
|
gridPaint,
|
|
);
|
|
}
|
|
if (series.points.isEmpty) return;
|
|
final values = series.points.map((point) => point.value).toList();
|
|
final minValue = values.reduce(math.min);
|
|
final maxValue = values.reduce(math.max);
|
|
final range = maxValue == minValue ? 1 : maxValue - minValue;
|
|
final offsets = <Offset>[
|
|
for (var index = 0; index < series.points.length; index++)
|
|
Offset(
|
|
chartRect.left +
|
|
(series.points.length == 1
|
|
? chartRect.width / 2
|
|
: chartRect.width * index / (series.points.length - 1)),
|
|
chartRect.bottom -
|
|
((series.points[index].value - minValue) / range) *
|
|
chartRect.height,
|
|
),
|
|
];
|
|
if (offsets.length > 1) {
|
|
final path = Path()..moveTo(offsets.first.dx, offsets.first.dy);
|
|
for (final offset in offsets.skip(1)) {
|
|
path.lineTo(offset.dx, offset.dy);
|
|
}
|
|
canvas.drawPath(
|
|
path,
|
|
Paint()
|
|
..color = theme.colorScheme.primary
|
|
..strokeWidth = 2.5
|
|
..style = PaintingStyle.stroke,
|
|
);
|
|
}
|
|
for (final offset in offsets) {
|
|
canvas.drawCircle(offset, 5, Paint()..color = tokens.accent);
|
|
canvas.drawCircle(
|
|
offset,
|
|
5,
|
|
Paint()
|
|
..color = theme.colorScheme.surface
|
|
..strokeWidth = 1.5
|
|
..style = PaintingStyle.stroke,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _ProgressionChartPainter oldDelegate) {
|
|
return oldDelegate.series != series || oldDelegate.theme != theme;
|
|
}
|
|
}
|
|
|
|
final class _SeriesSummaryView extends StatelessWidget {
|
|
const _SeriesSummaryView({required this.series});
|
|
|
|
final ProgressionExerciseSeries series;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final labels = switch (series.summary.kind) {
|
|
ProgressionSeriesSummaryKind.bestLast => [
|
|
('Meilleur', series.summary.best),
|
|
('Dernier', series.summary.last),
|
|
],
|
|
ProgressionSeriesSummaryKind.totals => [
|
|
('Total récent', series.summary.recentTotal),
|
|
('Dernière séance', series.summary.lastSessionTotal),
|
|
],
|
|
ProgressionSeriesSummaryKind.none => const <(String, Object?)>[],
|
|
};
|
|
if (labels.isEmpty) return const SizedBox.shrink();
|
|
return Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [
|
|
for (final item in labels)
|
|
_SummaryPill(
|
|
label: item.$1,
|
|
value: _formatMeasureValue(series.measure, item.$2),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _MetricTile extends StatelessWidget {
|
|
const _MetricTile({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = courtBlazerTokensOf(context);
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: tokens.border),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
value,
|
|
style: AppTextStyles.scoreNumber(
|
|
context,
|
|
).copyWith(color: Theme.of(context).colorScheme.primary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _SummaryPill extends StatelessWidget {
|
|
const _SummaryPill({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = courtBlazerTokensOf(context);
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: tokens.border),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: Text('$label : $value'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _ExerciseOptionLabel extends StatelessWidget {
|
|
const _ExerciseOptionLabel({required this.option});
|
|
|
|
final ProgressionExerciseOption option;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(child: Text(option.nameSnapshot)),
|
|
if (option.isArchived) ...[
|
|
const SizedBox(width: 8),
|
|
const Chip(
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
|
label: Text('Exercice archivé'),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _SectionTitle extends StatelessWidget {
|
|
const _SectionTitle(this.label);
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Text(label, style: Theme.of(context).textTheme.titleMedium);
|
|
}
|
|
}
|
|
|
|
final class _GlobalEmptyState extends StatelessWidget {
|
|
const _GlobalEmptyState({required this.onOpenHistory});
|
|
|
|
final VoidCallback onOpenHistory;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _CenteredProgressionMessage(
|
|
title: 'Aucune progression à afficher',
|
|
message:
|
|
'Termine une séance pour voir ton volume, ton rythme et tes résultats évoluer ici.',
|
|
action: OutlinedButton(
|
|
onPressed: onOpenHistory,
|
|
child: const Text('Voir les séances'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _CenteredProgressionMessage extends StatelessWidget {
|
|
const _CenteredProgressionMessage({
|
|
required this.title,
|
|
required this.message,
|
|
this.action,
|
|
});
|
|
|
|
final String title;
|
|
final String message;
|
|
final Widget? action;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
if (action != null) ...[const SizedBox(height: 16), action!],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _InlineProgressionMessage extends StatelessWidget {
|
|
const _InlineProgressionMessage({required this.title, required this.message});
|
|
|
|
final String title;
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = courtBlazerTokensOf(context);
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: tokens.border),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
|
const SizedBox(height: 6),
|
|
Text(message, style: Theme.of(context).textTheme.bodySmall),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
(String, String)? _stateMessage(ProgressionSeriesState state) {
|
|
return switch (state) {
|
|
ProgressionSeriesState.ready => null,
|
|
ProgressionSeriesState.singlePoint => (
|
|
'Encore un point de départ',
|
|
'Termine cet exercice dans une autre séance pour voir son évolution.',
|
|
),
|
|
ProgressionSeriesState.emptyForPeriod => (
|
|
'Pas encore de mesure exploitable',
|
|
'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.',
|
|
),
|
|
ProgressionSeriesState.emptyButHasAllTimeData => (
|
|
'Peu de données sur cette période',
|
|
'Passe sur "Tout" pour voir plus d\'historique.',
|
|
),
|
|
ProgressionSeriesState.noGraphableMeasure => (
|
|
'Pas encore de mesure exploitable',
|
|
'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.',
|
|
),
|
|
ProgressionSeriesState.textScoreOnly => (
|
|
'Score non graphique',
|
|
'Ce score est enregistré comme texte. Tu peux le retrouver dans le détail de l\'historique.',
|
|
),
|
|
};
|
|
}
|
|
|
|
Rect _pointHitRect(Size size, List<ProgressionPoint> points, int index) {
|
|
const hitSize = 44.0;
|
|
final chartRect = Rect.fromLTWH(8, 10, size.width - 16, size.height - 28);
|
|
if (points.isEmpty) return Rect.zero;
|
|
final values = points.map((point) => point.value).toList();
|
|
final minValue = values.reduce(math.min);
|
|
final maxValue = values.reduce(math.max);
|
|
final range = maxValue == minValue ? 1 : maxValue - minValue;
|
|
final x =
|
|
chartRect.left +
|
|
(points.length == 1
|
|
? chartRect.width / 2
|
|
: chartRect.width * index / (points.length - 1));
|
|
final y =
|
|
chartRect.bottom -
|
|
((points[index].value - minValue) / range) * chartRect.height;
|
|
return Rect.fromCenter(center: Offset(x, y), width: hitSize, height: hitSize);
|
|
}
|
|
|
|
String _pointDetailLabel(
|
|
ProgressionExerciseSeries series,
|
|
ProgressionPoint point,
|
|
) {
|
|
final label = switch (series.measure.measure) {
|
|
ProgressionMeasure.manualScore => 'Meilleur score',
|
|
ProgressionMeasure.stopwatchScore => 'Meilleur temps',
|
|
ProgressionMeasure.reps => 'Répétitions',
|
|
ProgressionMeasure.time => 'Temps',
|
|
};
|
|
return '$label : ${_formatMeasureValue(series.measure, point.rawValue)}';
|
|
}
|
|
|
|
String _formatMeasureValue(ProgressionMeasureOption measure, Object? rawValue) {
|
|
if (rawValue == null) return '-';
|
|
return switch (measure.measure) {
|
|
ProgressionMeasure.manualScore =>
|
|
'${_formatScore(rawValue as double)}${_scoreSuffix(measure.scoreUnit)}',
|
|
ProgressionMeasure.stopwatchScore => _formatStopwatch(rawValue as int),
|
|
ProgressionMeasure.reps => '${rawValue as int} répétitions',
|
|
ProgressionMeasure.time => _formatVolumeDuration(rawValue as int),
|
|
};
|
|
}
|
|
|
|
String _formatScore(double value) {
|
|
if (value == value.roundToDouble()) {
|
|
return value.round().toString();
|
|
}
|
|
return value.toStringAsFixed(1);
|
|
}
|
|
|
|
String _scoreSuffix(String? unit) {
|
|
final value = unit?.trim();
|
|
if (value == null || value.isEmpty) return '';
|
|
return ' $value';
|
|
}
|
|
|
|
String _formatVolumeDuration(int milliseconds) {
|
|
final duration = Duration(milliseconds: milliseconds);
|
|
final hours = duration.inHours;
|
|
final minutes = duration.inMinutes.remainder(60);
|
|
if (hours > 0) {
|
|
return minutes == 0
|
|
? '$hours h'
|
|
: '$hours h ${minutes.toString().padLeft(2, '0')}';
|
|
}
|
|
return '${minutes.toString().padLeft(2, '0')} min';
|
|
}
|
|
|
|
String _formatStopwatch(int milliseconds) {
|
|
final totalTenths = (milliseconds / 100).floor();
|
|
final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
|
|
final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
|
|
final tenths = totalTenths % 10;
|
|
return '$minutes:$seconds.$tenths';
|
|
}
|
|
|
|
String _formatDate(DateTime value) {
|
|
const months = [
|
|
'janvier',
|
|
'février',
|
|
'mars',
|
|
'avril',
|
|
'mai',
|
|
'juin',
|
|
'juillet',
|
|
'août',
|
|
'septembre',
|
|
'octobre',
|
|
'novembre',
|
|
'décembre',
|
|
];
|
|
return '${value.day} ${months[value.month - 1]}';
|
|
}
|