Files
GameTime/lib/presentation/workout_template_screen.dart
Blomios 36691643d7 feat(ui): integre affichage stats par scope et selection des types metier d'exercice cote telephone
UI telephone pour #179 (graphes/statistiques par etape, serie, exercice, seance) et #183 (choix des types metier d'exercice, hors pont Health Services).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:55:52 +02:00

1204 lines
40 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'example_badge.dart';
import 'library_tags.dart';
import 'share_screen.dart';
import 'workout_execution_screen.dart';
final class WorkoutTemplateListScreen extends StatefulWidget {
const WorkoutTemplateListScreen({
required this.workoutTemplateUseCases,
required this.programUseCases,
required this.activeUseCases,
required this.closeUseCase,
required this.historyUseCases,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.telemetryUseCases,
this.performanceReferenceUseCase,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
this.watchAlertPublisher,
super.key,
});
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final WorkoutTelemetryUseCases? telemetryUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final MediaUseCases? mediaUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
final WatchAlertPublisher? watchAlertPublisher;
@override
State<WorkoutTemplateListScreen> createState() =>
_WorkoutTemplateListScreenState();
}
final class _WorkoutTemplateListScreenState
extends State<WorkoutTemplateListScreen> {
final _searchController = TextEditingController();
var _selectedTags = <String>{};
late Future<List<WorkoutTemplate>> _templates;
@override
void initState() {
super.initState();
_templates = widget.workoutTemplateUseCases.listActive();
_searchController.addListener(() => setState(() {}));
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Séances')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(),
icon: const Icon(Icons.add),
label: const Text('Créer'),
),
body: FutureBuilder<List<WorkoutTemplate>>(
future: _templates,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final templates = snapshot.data ?? const <WorkoutTemplate>[];
final tagSuggestions = tagSuggestionsFor(
templates,
(template) => template.tags,
).map((usage) => usage.tag).toList();
if (templates.isEmpty) {
return _CenteredMessage(
title: 'Aucune séance',
message:
'Crée une séance-modèle pour lancer ton entraînement plus vite.',
actionLabel: 'Créer une séance',
onAction: () => _openForm(),
);
}
final filtered = _filter(templates);
return ListView.separated(
itemCount: filtered.isEmpty ? 2 : filtered.length + 1,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) {
return _WorkoutTemplateFilters(
controller: _searchController,
tagSuggestions: tagSuggestions,
selectedTags: _selectedTags,
filtersActive: _filtersActive,
onToggleTag: _toggleTag,
onClearFilters: _clearFilters,
);
}
if (filtered.isEmpty) {
return _CenteredMessage(
title: 'Aucune séance ne correspond',
message: 'Modifie la recherche ou les tags sélectionnés.',
actionLabel: 'Effacer les filtres',
onAction: _clearFilters,
);
}
final template = filtered[index - 1];
return ListTile(
title: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(template.name),
if (template.isExample) const ExampleBadge(),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_templateSummary(template)),
if (template.tags.isNotEmpty) ...[
const SizedBox(height: 6),
TagBadgeWrap(tags: template.tags),
],
],
),
trailing: Wrap(
spacing: 8,
children: [
TextButton(
onPressed: () => _start(template),
child: const Text('Lancer'),
),
PopupMenuButton<_WorkoutTemplateItemAction>(
tooltip: 'Actions',
onSelected: (action) {
switch (action) {
case _WorkoutTemplateItemAction.duplicate:
unawaited(_duplicate(template));
case _WorkoutTemplateItemAction.delete:
unawaited(_confirmDelete(template));
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: _WorkoutTemplateItemAction.duplicate,
child: Text('Dupliquer'),
),
PopupMenuItem(
value: _WorkoutTemplateItemAction.delete,
child: Text('Supprimer'),
),
],
),
const Icon(Icons.chevron_right),
],
),
onTap: () => _openForm(template: template),
);
},
);
},
),
);
}
void _reload() {
setState(() {
_templates = widget.workoutTemplateUseCases.listActive();
});
}
List<WorkoutTemplate> _filter(List<WorkoutTemplate> templates) {
final query = _searchController.text.trim().toLowerCase();
final matchingQuery = templates.where((template) {
return query.isEmpty ||
template.name.toLowerCase().contains(query) ||
_templateSummary(template).toLowerCase().contains(query);
}).toList();
return filterByRequiredTags(
matchingQuery,
_selectedTags,
(template) => template.tags,
);
}
bool get _filtersActive {
return _searchController.text.trim().isNotEmpty || _selectedTags.isNotEmpty;
}
void _toggleTag(String tag) {
setState(() {
if (_selectedTags.contains(tag)) {
_selectedTags = {..._selectedTags}..remove(tag);
} else {
_selectedTags = {..._selectedTags, tag};
}
});
}
void _clearFilters() {
setState(() {
_searchController.clear();
_selectedTags = {};
});
}
Future<void> _openForm({
WorkoutTemplate? template,
String? initialSnackBar,
}) async {
final templates = await _templates;
if (!mounted) return;
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => WorkoutTemplateFormScreen(
workoutTemplateUseCases: widget.workoutTemplateUseCases,
programUseCases: widget.programUseCases,
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
template: template,
tagSuggestions: tagSuggestionsFor(
templates,
(template) => template.tags,
).map((usage) => usage.tag).toList(),
initialSnackBar: initialSnackBar,
),
),
);
if (changed == true) {
if (!mounted) return;
_reload();
}
}
Future<void> _duplicate(WorkoutTemplate template) async {
try {
final copy = await widget.workoutTemplateUseCases.duplicate(
template.metadata.id,
);
if (!mounted) return;
await _openForm(
template: copy,
initialSnackBar:
'Séance dupliquée. Ajuste la copie avant de la lancer.',
);
if (!mounted) return;
_reload();
} on Exception {
_showSnackBar('Duplication impossible pour le moment.');
}
}
Future<void> _start(WorkoutTemplate template) async {
if (_templateExerciseCount(template) == 0) {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!mounted) return;
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
'den lancer une nouvelle.',
),
),
);
return;
}
final ActiveWorkoutSession session;
try {
session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
} on DomainException {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases,
sensorUseCases: widget.sensorUseCases,
telemetryUseCases: widget.telemetryUseCases,
watchAlertPublisher: widget.watchAlertPublisher,
),
),
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await widget.activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await widget.activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a new start.
}
return false;
}
Future<void> _confirmDelete(WorkoutTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Supprimer ${template.name} ?'),
content: const Text('Cette action est irréversible.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton.icon(
onPressed: () => Navigator.of(context).pop(true),
icon: const Icon(Icons.delete_outline),
label: const Text('Supprimer'),
),
],
),
);
if (confirmed != true) return;
try {
await widget.workoutTemplateUseCases.delete(template.metadata.id);
if (!mounted) return;
_reload();
} on Exception catch (error) {
_showSnackBar(error.toString());
}
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
enum _WorkoutTemplateItemAction { duplicate, delete }
final class _WorkoutTemplateFilters extends StatelessWidget {
const _WorkoutTemplateFilters({
required this.controller,
required this.tagSuggestions,
required this.selectedTags,
required this.filtersActive,
required this.onToggleTag,
required this.onClearFilters,
});
final TextEditingController controller;
final List<String> tagSuggestions;
final Set<String> selectedTags;
final bool filtersActive;
final ValueChanged<String> onToggleTag;
final VoidCallback onClearFilters;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Rechercher',
prefixIcon: Icon(Icons.search),
),
),
if (tagSuggestions.isNotEmpty) ...[
const SizedBox(height: 12),
LibraryTagFilters(
suggestions: tagSuggestions,
selectedTags: selectedTags,
onToggle: onToggleTag,
),
],
if (filtersActive) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: onClearFilters,
child: const Text('Effacer les filtres'),
),
),
],
],
),
);
}
}
final class WorkoutTemplateFormScreen extends StatefulWidget {
const WorkoutTemplateFormScreen({
required this.workoutTemplateUseCases,
required this.programUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
this.template,
this.tagSuggestions = const [],
this.initialSnackBar,
super.key,
});
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ProgramUseCases programUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
final WorkoutTemplate? template;
final List<String> tagSuggestions;
final String? initialSnackBar;
@override
State<WorkoutTemplateFormScreen> createState() =>
_WorkoutTemplateFormScreenState();
}
final class _WorkoutTemplateFormScreenState
extends State<WorkoutTemplateFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final List<WorkoutTemplateProgramDraft> _programs;
late List<String> _tags;
var _saving = false;
bool get _isEditing => widget.template != null;
@override
void initState() {
super.initState();
final template = widget.template;
_nameController = TextEditingController(text: template?.name);
_programs = [
for (final program
in template?.programs ?? const <WorkoutTemplateProgram>[])
WorkoutTemplateProgramDraft.fromTemplateProgram(
program,
template?.overrides ?? const <WorkoutTemplateExerciseOverride>[],
),
];
_tags = List<String>.of(template?.tags ?? const []);
final initialSnackBar = widget.initialSnackBar;
if (initialSnackBar != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showSnackBar(initialSnackBar);
});
}
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final canSave = !_saving && _programs.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
actions: [
if (_isEditing && widget.shareUseCases != null)
IconButton(
tooltip: 'Partager',
icon: const Icon(Icons.ios_share),
onPressed: _shareTemplate,
),
],
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: canSave ? _save : null,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Nom'),
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
: null,
),
const SizedBox(height: 12),
EditableTagsSection(
tags: _tags,
suggestions: widget.tagSuggestions,
onChanged: (tags) => setState(() => _tags = tags),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _addProgram,
icon: const Icon(Icons.add),
label: const Text('Ajouter un programme'),
),
),
],
),
),
),
Expanded(
child: _programs.isEmpty
? const _CenteredMessage(
title: 'Aucun programme ajouté',
message:
'Ajoute au moins un programme pour enregistrer cette séance.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _programs.length,
onReorderItem: _reorderProgram,
itemBuilder: (context, index) {
final program = _programs[index];
return Card(
key: ValueKey(program.clientKey),
child: ListTile(
leading: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
title: Text(program.programNameSnapshot),
subtitle: Text(_programDraftSummary(program)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openProgramDetail(program),
),
);
},
),
),
],
),
);
}
Future<void> _addProgram() async {
final program = await Navigator.of(context).push<Program>(
MaterialPageRoute(
builder: (context) =>
ProgramSelectionScreen(programUseCases: widget.programUseCases),
),
);
if (program == null) return;
if (!mounted) return;
final shouldExplainSnapshot = _programs.isEmpty;
setState(() {
_programs.add(WorkoutTemplateProgramDraft.fromProgram(program));
});
if (shouldExplainSnapshot) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une copie de ce programme sera enregistrée dans la séance. '
'Les futures modifications du programme original ne changeront '
'pas cette séance.',
),
),
);
}
}
void _reorderProgram(int oldIndex, int newIndex) {
setState(() {
final item = _programs.removeAt(oldIndex);
_programs.insert(newIndex, item);
});
}
Future<void> _openProgramDetail(WorkoutTemplateProgramDraft program) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) =>
WorkoutTemplateProgramDetailScreen(program: program),
),
);
if (!mounted) return;
setState(() {});
}
Future<void> _save() async {
if (_programs.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _saving = true);
try {
await widget.workoutTemplateUseCases.saveConfigured(
id: widget.template?.metadata.id,
name: _nameController.text.trim(),
tags: _tags,
programs: _programs.map((program) => program.toConfig()).toList(),
overrides: _programs
.expand((program) => program.toOverrides())
.toList(),
);
if (mounted) {
Navigator.of(context).pop(true);
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
Future<void> _shareTemplate() async {
final template = widget.template;
final shareUseCases = widget.shareUseCases;
if (template == null || shareUseCases == null) return;
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => ShareFormScreen(
shareUseCases: shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
resourceType: ShareResourceType.workoutTemplate,
localResourceId: template.metadata.id,
resourceName: template.name,
resourceSummary: _templateSummary(template),
),
),
);
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
final class ProgramSelectionScreen extends StatelessWidget {
const ProgramSelectionScreen({required this.programUseCases, super.key});
final ProgramUseCases programUseCases;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ajouter un programme')),
body: FutureBuilder<List<Program>>(
future: programUseCases.listActive(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final programs = snapshot.data ?? const <Program>[];
if (programs.isEmpty) {
return const _CenteredMessage(
title: 'Aucun programme disponible',
message: 'Crée un programme avant de composer une séance.',
);
}
return ListView.separated(
itemCount: programs.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final program = programs[index];
return ListTile(
title: Text(program.name),
subtitle: Text(_programSummary(program)),
onTap: () => Navigator.of(context).pop(program),
);
},
);
},
),
);
}
}
final class WorkoutTemplateProgramDetailScreen extends StatefulWidget {
const WorkoutTemplateProgramDetailScreen({required this.program, super.key});
final WorkoutTemplateProgramDraft program;
@override
State<WorkoutTemplateProgramDetailScreen> createState() =>
_WorkoutTemplateProgramDetailScreenState();
}
final class _WorkoutTemplateProgramDetailScreenState
extends State<WorkoutTemplateProgramDetailScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.program.programNameSnapshot)),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: widget.program.exercises.length,
itemBuilder: (context, index) {
final exercise = widget.program.exercises[index];
final autoStartNextTimedStepCustomized =
exercise.autoStartNextTimedStepOverride != null;
final autoStartNextTimedStepStateLabel =
autoStartNextTimedStepCustomized
? 'Personnalisé pour cette séance'
: 'Réglage du programme';
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
exercise.exerciseNameSnapshot,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(_exerciseDraftSummary(exercise)),
const SizedBox(height: 12),
TextFormField(
initialValue:
'${exercise.setsCountOverride ?? exercise.setsCount}',
decoration: const InputDecoration(
labelText: 'Nombre de séries',
),
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
exercise.setsCountOverride = int.tryParse(value);
});
},
),
if (exercise.timeEnabled)
_NumberField(
label: 'Cible temps (s)',
initialValue:
exercise.targetTimeSecondsOverride ??
exercise.targetTimeSeconds,
onChanged: (value) {
setState(() {
exercise.targetTimeSecondsOverride = value?.round();
});
},
),
if (exercise.repsEnabled)
_NumberField(
label: 'Cible répétitions',
initialValue:
exercise.targetRepsOverride ?? exercise.targetReps,
onChanged: (value) {
setState(() {
exercise.targetRepsOverride = value?.round();
});
},
),
if (exercise.scoreEnabled)
_NumberField(
label: exercise.scoreUnitSnapshot == null
? 'Cible score'
: 'Cible score (${exercise.scoreUnitSnapshot})',
initialValue:
exercise.targetScoreOverride ?? exercise.targetScore,
onChanged: (value) {
setState(() {
exercise.targetScoreOverride = value;
});
},
),
if (exercise.exerciseStepsSnapshot.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Séquence',
style: Theme.of(context).textTheme.titleMedium,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'Enchaîner automatiquement les chronos consécutifs',
),
subtitle: Text(
'$autoStartNextTimedStepStateLabel\n'
'${_autoStartHelpText(exercise.autoStartNextTimedStepEffective)}',
),
value: exercise.autoStartNextTimedStepEffective,
onChanged: (value) {
setState(() {
exercise.autoStartNextTimedStepOverride = value;
});
},
),
if (autoStartNextTimedStepCustomized)
TextButton(
onPressed: () {
setState(() {
exercise.autoStartNextTimedStepOverride = null;
});
},
child: const Text('Revenir au réglage du programme'),
),
],
],
),
),
);
},
),
);
}
}
final class WorkoutTemplateProgramDraft {
WorkoutTemplateProgramDraft({
required this.clientKey,
this.existingMetadata,
this.sourceProgramId,
required this.programNameSnapshot,
required this.defaultRestSecondsSnapshot,
required this.programSnapshotJson,
required this.exercises,
});
factory WorkoutTemplateProgramDraft.fromProgram(Program program) {
final snapshot = {
'programId': program.metadata.id,
'name': program.name,
'defaultRestSeconds': program.defaultRestSeconds,
'exercises': program.exercises
.map((exercise) => exercise.toSnapshotJson())
.toList(),
};
return WorkoutTemplateProgramDraft(
clientKey: UniqueKey().toString(),
sourceProgramId: program.metadata.id,
programNameSnapshot: program.name,
defaultRestSecondsSnapshot: program.defaultRestSeconds,
programSnapshotJson: jsonEncode(snapshot),
exercises: _parseExercises(snapshot),
);
}
factory WorkoutTemplateProgramDraft.fromTemplateProgram(
WorkoutTemplateProgram program,
List<WorkoutTemplateExerciseOverride> overrides,
) {
final snapshot =
jsonDecode(program.programSnapshotJson) as Map<String, dynamic>;
final exercises = _parseExercises(snapshot);
for (final exercise in exercises) {
for (final override in overrides) {
if (override.workoutTemplateProgramId == program.metadata.id &&
override.snapshotProgramExerciseId == exercise.snapshotExerciseId) {
exercise.existingOverrideMetadata = override.metadata;
exercise.setsCountOverride = override.setsCountOverride;
exercise.targetTimeSecondsOverride =
override.targetTimeSecondsOverride;
exercise.targetRepsOverride = override.targetRepsOverride;
exercise.targetScoreOverride = override.targetScoreOverride;
exercise.autoStartNextTimedStepOverride =
override.autoStartNextTimedStepOverride;
}
}
}
return WorkoutTemplateProgramDraft(
clientKey: program.metadata.id,
existingMetadata: program.metadata,
sourceProgramId: program.sourceProgramId,
programNameSnapshot: program.programNameSnapshot,
defaultRestSecondsSnapshot: program.defaultRestSecondsSnapshot,
programSnapshotJson: program.programSnapshotJson,
exercises: exercises,
);
}
final String clientKey;
final EntityMetadata? existingMetadata;
final String? sourceProgramId;
final String programNameSnapshot;
final int defaultRestSecondsSnapshot;
final String programSnapshotJson;
final List<WorkoutTemplateExerciseDraft> exercises;
WorkoutTemplateProgramConfig toConfig() {
return WorkoutTemplateProgramConfig(
clientKey: clientKey,
existingMetadata: existingMetadata,
sourceProgramId: sourceProgramId,
programNameSnapshot: programNameSnapshot,
defaultRestSecondsSnapshot: defaultRestSecondsSnapshot,
programSnapshotJson: programSnapshotJson,
);
}
Iterable<WorkoutTemplateExerciseOverrideConfig> toOverrides() sync* {
for (final exercise in exercises) {
if (!exercise.hasOverride) {
continue;
}
yield WorkoutTemplateExerciseOverrideConfig(
existingMetadata: exercise.existingOverrideMetadata,
workoutTemplateProgramClientKey: clientKey,
snapshotProgramExerciseId: exercise.snapshotExerciseId,
setsCountOverride: exercise.setsCountOverride,
targetTimeSecondsOverride: exercise.targetTimeSecondsOverride,
targetRepsOverride: exercise.targetRepsOverride,
targetScoreOverride: exercise.targetScoreOverride,
autoStartNextTimedStepOverride: exercise.autoStartNextTimedStepOverride,
);
}
}
}
final class WorkoutTemplateExerciseDraft {
WorkoutTemplateExerciseDraft({
required this.snapshotExerciseId,
required this.exerciseNameSnapshot,
required this.setsCount,
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
this.exerciseStepsSnapshot = const [],
this.autoStartNextTimedStepSnapshot = true,
this.scoreUnitSnapshot,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.existingOverrideMetadata,
this.setsCountOverride,
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
this.autoStartNextTimedStepOverride,
});
final String snapshotExerciseId;
final String exerciseNameSnapshot;
final int setsCount;
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool autoStartNextTimedStepSnapshot;
final String? scoreUnitSnapshot;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
EntityMetadata? existingOverrideMetadata;
int? setsCountOverride;
int? targetTimeSecondsOverride;
int? targetRepsOverride;
double? targetScoreOverride;
bool? autoStartNextTimedStepOverride;
bool get autoStartNextTimedStepEffective {
return autoStartNextTimedStepOverride ?? autoStartNextTimedStepSnapshot;
}
bool get hasOverride {
return setsCountOverride != null ||
targetTimeSecondsOverride != null ||
targetRepsOverride != null ||
targetScoreOverride != null ||
autoStartNextTimedStepOverride != null;
}
}
final class _NumberField extends StatelessWidget {
const _NumberField({
required this.label,
required this.initialValue,
required this.onChanged,
});
final String label;
final num? initialValue;
final ValueChanged<double?> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
initialValue: initialValue?.toString(),
decoration: InputDecoration(labelText: label),
keyboardType: TextInputType.number,
onChanged: (value) => onChanged(double.tryParse(value)),
),
);
}
}
List<WorkoutTemplateExerciseDraft> _parseExercises(
Map<String, dynamic> snapshot,
) {
final rawExercises = snapshot['exercises'] as List<dynamic>? ?? const [];
return rawExercises.map((raw) {
final exercise = raw as Map<String, dynamic>;
final autoStartNextTimedStepSnapshot =
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
(exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
true;
return WorkoutTemplateExerciseDraft(
snapshotExerciseId: exercise['id'] as String,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String,
setsCount: exercise['setsCount'] as int,
timeEnabled: exercise['timeEnabled'] as bool,
repsEnabled: exercise['repsEnabled'] as bool,
scoreEnabled: exercise['scoreEnabled'] as bool,
exerciseStepsSnapshot: _parseExerciseSteps(
exercise['exerciseStepsSnapshot'],
),
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
);
}).toList();
}
List<ExerciseStep> _parseExerciseSteps(Object? value) {
final rawSteps = value as List<dynamic>? ?? const [];
return rawSteps.map((raw) {
final step = raw as Map<String, dynamic>;
return ExerciseStep(
id: step['id'] as String,
position: step['position'] as int,
name: step['name'] as String,
type: ExerciseStepType.values.byName(step['type'] as String),
defaultTargetValue: step['defaultTargetValue'] as int,
hasScore: step['hasScore'] as bool? ?? false,
scoreInputMode: ScoreInputMode.values.byName(
step['scoreInputMode'] as String? ?? ScoreInputMode.manual.name,
),
scoreLabel: step['scoreLabel'] as String?,
scoreUnit: step['scoreUnit'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
);
}).toList();
}
String _autoStartHelpText(bool active) {
return active
? 'Le chrono suivant démarre dès que le précédent arrive à 0.'
: "L'app attendra ton démarrage avant de lancer le chrono suivant.";
}
String _templateSummary(WorkoutTemplate template) {
final programCount = template.programs.length;
final exerciseCount = _templateExerciseCount(template);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
int _templateExerciseCount(WorkoutTemplate template) {
return template.programs.fold<int>(
0,
(total, program) => total + _exerciseCount(program.programSnapshotJson),
);
}
String _programDraftSummary(WorkoutTemplateProgramDraft program) {
final exerciseCount = program.exercises.length;
return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _programSummary(Program program) {
final exerciseCount = program.exercises.length;
final setsCount = program.exercises.fold<int>(
0,
(total, exercise) => total + exercise.setsCount,
);
return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''} · '
'$setsCount série${setsCount > 1 ? 's' : ''}';
}
String _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) {
final measures = [
if (exercise.timeEnabled) 'Temps',
if (exercise.repsEnabled) 'Répétitions',
if (exercise.scoreEnabled)
exercise.scoreUnitSnapshot == null
? 'Score'
: 'Score (${exercise.scoreUnitSnapshot})',
].join(' + ');
final sets = exercise.setsCountOverride ?? exercise.setsCount;
return '$sets séries · $measures';
}
int _exerciseCount(String programSnapshotJson) {
try {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
} on Exception {
return 0;
}
}
final class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({
required this.title,
this.message,
this.actionLabel,
this.onAction,
});
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
if (message != null) ...[
const SizedBox(height: 8),
Text(message!, textAlign: TextAlign.center),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
FilledButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}