Files
GameTime/lib/presentation/program_screen.dart
Blomios 83f57b5640 merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre
Intègre le commit de clôture du lot #91 resté non fusionné. Conflits
attendus sur entities.dart, workout_execution_screen.dart et
watch_companion_projection_test.dart : arbitrage Architect - garder la
base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter
uniquement les invariants chronos utiles (Durée / Temps réalisé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	lib/domain/entities.dart
#	lib/presentation/workout_execution_screen.dart
#	test/application/watch_companion_projection_test.dart
2026-07-28 11:35:22 +02:00

1440 lines
48 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'example_badge.dart';
import 'exercise_library_screen.dart';
import 'library_tags.dart';
import 'share_screen.dart';
final class ProgramListScreen extends StatefulWidget {
const ProgramListScreen({
required this.programUseCases,
required this.exerciseUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
super.key,
});
final ProgramUseCases programUseCases;
final ExerciseUseCases exerciseUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
@override
State<ProgramListScreen> createState() => _ProgramListScreenState();
}
final class _ProgramListScreenState extends State<ProgramListScreen> {
final _searchController = TextEditingController();
var _selectedTags = <String>{};
late Future<List<Program>> _programs;
@override
void initState() {
super.initState();
_programs = widget.programUseCases.listActive();
_searchController.addListener(() => setState(() {}));
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Programmes')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(),
icon: const Icon(Icons.add),
label: const Text('Créer'),
),
body: FutureBuilder<List<Program>>(
future: _programs,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return _CenteredMessage(
title: 'Impossible de charger les programmes',
actionLabel: 'Réessayer',
onAction: _reload,
);
}
final programs = snapshot.data ?? const <Program>[];
final tagSuggestions = tagSuggestionsFor(
programs,
(program) => program.tags,
).map((usage) => usage.tag).toList();
if (programs.isEmpty) {
return _CenteredMessage(
title: 'Aucun programme',
message: 'Assemble quelques exercices pour préparer une séance.',
actionLabel: 'Créer un programme',
onAction: () => _openForm(),
);
}
final filtered = _filter(programs);
return ListView.separated(
itemCount: filtered.isEmpty ? 2 : filtered.length + 1,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) {
return _ProgramFilters(
controller: _searchController,
tagSuggestions: tagSuggestions,
selectedTags: _selectedTags,
filtersActive: _filtersActive,
onToggleTag: _toggleTag,
onClearFilters: _clearFilters,
);
}
if (filtered.isEmpty) {
return _CenteredMessage(
title: 'Aucun programme ne correspond',
message: 'Modifie la recherche ou les tags sélectionnés.',
actionLabel: 'Effacer les filtres',
onAction: _clearFilters,
);
}
final program = filtered[index - 1];
return ListTile(
title: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(program.name),
if (program.isExample) const ExampleBadge(),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_programSummary(program)),
if (program.tags.isNotEmpty) ...[
const SizedBox(height: 6),
TagBadgeWrap(tags: program.tags),
],
],
),
trailing: Wrap(
spacing: 8,
children: [
PopupMenuButton<_ProgramItemAction>(
tooltip: 'Actions',
onSelected: (action) {
switch (action) {
case _ProgramItemAction.duplicate:
unawaited(_duplicate(program));
case _ProgramItemAction.delete:
unawaited(_confirmDelete(program));
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: _ProgramItemAction.duplicate,
child: Text('Dupliquer'),
),
PopupMenuItem(
value: _ProgramItemAction.delete,
child: Text('Supprimer'),
),
],
),
const Icon(Icons.chevron_right),
],
),
onTap: () => _openForm(program: program),
);
},
);
},
),
);
}
void _reload() {
setState(() {
_programs = widget.programUseCases.listActive();
});
}
List<Program> _filter(List<Program> programs) {
final query = _searchController.text.trim().toLowerCase();
final matchingQuery = programs.where((program) {
return query.isEmpty ||
program.name.toLowerCase().contains(query) ||
_programSummary(program).toLowerCase().contains(query);
}).toList();
return filterByRequiredTags(
matchingQuery,
_selectedTags,
(program) => program.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({Program? program, String? initialSnackBar}) async {
final programs = await _programs;
if (!mounted) return;
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => ProgramFormScreen(
programUseCases: widget.programUseCases,
exerciseUseCases: widget.exerciseUseCases,
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
program: program,
tagSuggestions: tagSuggestionsFor(
programs,
(program) => program.tags,
).map((usage) => usage.tag).toList(),
initialSnackBar: initialSnackBar,
),
),
);
if (changed == true) {
if (!mounted) return;
_reload();
}
}
Future<void> _duplicate(Program program) async {
try {
final copy = await widget.programUseCases.duplicate(program.metadata.id);
if (!mounted) return;
await _openForm(
program: copy,
initialSnackBar:
"Programme dupliqué. Ajuste la copie avant de l'utiliser.",
);
if (!mounted) return;
_reload();
} on Exception {
_showSnackBar('Duplication impossible pour le moment.');
}
}
Future<void> _confirmDelete(Program program) async {
final used = await widget.programUseCases.isUsedInWorkoutTemplates(
program.metadata.id,
);
if (!mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Supprimer ${program.name} ?'),
content: Text(
used
? 'Ce programme est utilisé dans une ou plusieurs séances. '
'Le supprimer le retirera de ces séances. Continuer ?'
: '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.programUseCases.delete(program.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 _ProgramItemAction { duplicate, delete }
final class _ProgramFilters extends StatelessWidget {
const _ProgramFilters({
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 ProgramFormScreen extends StatefulWidget {
const ProgramFormScreen({
required this.programUseCases,
required this.exerciseUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
this.program,
this.tagSuggestions = const [],
this.initialSnackBar,
super.key,
});
final ProgramUseCases programUseCases;
final ExerciseUseCases exerciseUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
final Program? program;
final List<String> tagSuggestions;
final String? initialSnackBar;
@override
State<ProgramFormScreen> createState() => _ProgramFormScreenState();
}
final class _ProgramFormScreenState extends State<ProgramFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _defaultRestController;
late final List<_ProgramExerciseDraft> _exercises;
late List<String> _tags;
Timer? _addedSnackBarTimer;
var _saving = false;
bool get _isEditing => widget.program != null;
int get _defaultRestSeconds {
return int.tryParse(_defaultRestController.text.trim()) ?? 0;
}
@override
void initState() {
super.initState();
final program = widget.program;
_nameController = TextEditingController(text: program?.name);
_defaultRestController = TextEditingController(
text: '${program?.defaultRestSeconds ?? 60}',
);
_exercises = [
for (final exercise in program?.exercises ?? const <ProgramExercise>[])
_ProgramExerciseDraft.fromProgramExercise(exercise),
];
_tags = List<String>.of(program?.tags ?? const []);
final initialSnackBar = widget.initialSnackBar;
if (initialSnackBar != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showSnackBar(initialSnackBar);
});
}
}
@override
void dispose() {
_addedSnackBarTimer?.cancel();
_nameController.dispose();
_defaultRestController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final canSave = !_saving && _exercises.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(
_isEditing ? 'Modifier le programme' : 'Créer un programme',
),
actions: [
if (_isEditing && widget.shareUseCases != null)
IconButton(
tooltip: 'Partager',
icon: const Icon(Icons.ios_share),
onPressed: _shareProgram,
),
],
),
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),
TextFormField(
controller: _defaultRestController,
decoration: const InputDecoration(
labelText: 'Repos par défaut (s)',
),
keyboardType: TextInputType.number,
validator: _nonNegativeIntValidator,
onChanged: (_) => setState(() {}),
),
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: _addExercise,
icon: const Icon(Icons.add),
label: const Text('Ajouter un exercice'),
),
),
],
),
),
),
Expanded(
child: _exercises.isEmpty
? const _CenteredMessage(
title: 'Aucun exercice ajouté',
message:
'Ajoute au moins un exercice pour enregistrer ce programme.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _exercises.length,
onReorderItem: _reorderExercise,
itemBuilder: (context, index) {
final exercise = _exercises[index];
return _ProgramExerciseCard(
key: ValueKey(exercise.key),
draft: exercise,
defaultRestSeconds: _defaultRestSeconds,
onCustomize: () => _customizeExercise(exercise.key),
index: index,
);
},
),
),
],
),
);
}
Future<void> _addExercise() async {
final exercise = await Navigator.of(context).push<Exercise>(
MaterialPageRoute(
builder: (context) =>
ExerciseSelectionScreen(exerciseUseCases: widget.exerciseUseCases),
),
);
if (exercise == null) return;
if (!mounted) return;
final draft = _ProgramExerciseDraft.fromExercise(
exercise,
restSecondsOverride: _defaultRestSeconds,
);
setState(() {
_exercises.add(draft);
});
final messenger = ScaffoldMessenger.of(context);
_addedSnackBarTimer?.cancel();
_addedSnackBarTimer = Timer(const Duration(seconds: 3), () {
if (mounted) {
messenger.hideCurrentSnackBar();
}
});
messenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text('Exercice ajouté'),
action: SnackBarAction(
label: 'Personnaliser',
onPressed: () {
_addedSnackBarTimer?.cancel();
_addedSnackBarTimer = null;
messenger.hideCurrentSnackBar();
_customizeExercise(draft.key);
},
),
),
);
}
Future<void> _customizeExercise(String key) async {
final index = _exercises.indexWhere((exercise) => exercise.key == key);
if (index == -1) return;
final result = await Navigator.of(context).push<_ProgramExerciseEditResult>(
MaterialPageRoute(
builder: (context) => _ProgramExerciseCustomizationScreen(
draft: _exercises[index],
defaultRestSeconds: _defaultRestSeconds,
),
),
);
if (result == null || !mounted) return;
setState(() {
final currentIndex = _exercises.indexWhere(
(exercise) => exercise.key == key,
);
if (currentIndex == -1) return;
if (result.deleted) {
_exercises.removeAt(currentIndex);
} else if (result.draft != null) {
_exercises[currentIndex] = result.draft!;
}
});
}
void _reorderExercise(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _exercises.removeAt(oldIndex);
_exercises.insert(newIndex, item);
});
}
Future<void> _save() async {
if (_exercises.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _saving = true);
try {
await widget.programUseCases.saveConfigured(
id: widget.program?.metadata.id,
name: _nameController.text.trim(),
defaultRestSeconds: _defaultRestSeconds,
tags: _tags,
exercises: _exercises.map((exercise) => exercise.toConfig()).toList(),
);
if (mounted) {
Navigator.of(context).pop(true);
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
Future<void> _shareProgram() async {
final program = widget.program;
final shareUseCases = widget.shareUseCases;
if (program == null || shareUseCases == null) return;
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => ShareFormScreen(
shareUseCases: shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
resourceType: ShareResourceType.program,
localResourceId: program.metadata.id,
resourceName: program.name,
resourceSummary: _programSummary(program),
),
),
);
}
String? _nonNegativeIntValidator(String? value) {
final number = int.tryParse(value?.trim() ?? '');
if (number == null || number < 0) {
return 'Saisis un nombre positif.';
}
return null;
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
final class ExerciseSelectionScreen extends StatefulWidget {
const ExerciseSelectionScreen({required this.exerciseUseCases, super.key});
final ExerciseUseCases exerciseUseCases;
@override
State<ExerciseSelectionScreen> createState() =>
_ExerciseSelectionScreenState();
}
final class _ExerciseSelectionScreenState
extends State<ExerciseSelectionScreen> {
final _searchController = TextEditingController();
var _selectedMeasures = <WorkoutMeasure>{};
late Future<List<Exercise>> _exercises;
@override
void initState() {
super.initState();
_exercises = widget.exerciseUseCases.listActive();
_searchController.addListener(() => setState(() {}));
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ajouter un exercice')),
body: FutureBuilder<List<Exercise>>(
future: _exercises,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final exercises = _filter(snapshot.data ?? const <Exercise>[]);
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: 'Rechercher',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: WorkoutMeasure.values.map((measure) {
return FilterChip(
label: Text(_measureLabel(measure)),
selected: _selectedMeasures.contains(measure),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedMeasures = {
..._selectedMeasures,
measure,
};
} else {
_selectedMeasures = {..._selectedMeasures}
..remove(measure);
}
});
},
);
}).toList(),
),
],
),
),
if (exercises.isEmpty)
const _CenteredMessage(
title: 'Aucun exercice disponible',
message: 'Crée un exercice dans la bibliothèque.',
)
else
for (final exercise in exercises)
ExerciseListTile(
exercise: exercise,
onTap: () => Navigator.of(context).pop(exercise),
),
],
);
},
),
);
}
List<Exercise> _filter(List<Exercise> exercises) {
final query = _searchController.text.trim().toLowerCase();
return exercises.where((exercise) {
final matchesQuery =
query.isEmpty ||
exercise.name.toLowerCase().contains(query) ||
(exercise.description?.toLowerCase().contains(query) ?? false);
final matchesMeasures =
_selectedMeasures.isEmpty ||
_selectedMeasures.every(exercise.availableMeasures.contains);
return matchesQuery && matchesMeasures;
}).toList();
}
}
final class _ProgramExerciseCard extends StatelessWidget {
const _ProgramExerciseCard({
required this.draft,
required this.defaultRestSeconds,
required this.onCustomize,
required this.index,
super.key,
});
final _ProgramExerciseDraft draft;
final int defaultRestSeconds;
final VoidCallback onCustomize;
final int index;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
title: Text(draft.exerciseNameSnapshot),
subtitle: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(_restLabel(draft, defaultRestSeconds)),
if (draft.exerciseArchivedSnapshot)
const Chip(label: Text('Exercice archivé')),
],
),
trailing: IconButton(
tooltip: "Personnaliser l'exercice",
onPressed: onCustomize,
icon: const Icon(Icons.tune),
),
),
);
}
}
final class _ProgramExerciseCustomizationScreen extends StatefulWidget {
const _ProgramExerciseCustomizationScreen({
required _ProgramExerciseDraft draft,
required this.defaultRestSeconds,
}) : _initialDraft = draft;
final _ProgramExerciseDraft _initialDraft;
final int defaultRestSeconds;
@override
State<_ProgramExerciseCustomizationScreen> createState() =>
_ProgramExerciseCustomizationScreenState();
}
final class _ProgramExerciseCustomizationScreenState
extends State<_ProgramExerciseCustomizationScreen> {
final _formKey = GlobalKey<FormState>();
late final _ProgramExerciseDraft _draft;
late final TextEditingController _setsController;
late final TextEditingController _restController;
String? _measureError;
@override
void initState() {
super.initState();
_draft = widget._initialDraft.copy();
_setsController = TextEditingController(text: '${_draft.setsCount}');
_restController = TextEditingController(
text: '${_draft.restSecondsOverride ?? widget.defaultRestSeconds}',
);
}
@override
void dispose() {
_setsController.dispose();
_restController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scoreUnit = _draft.scoreUnitSnapshot;
final hasStopwatchScore =
_draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch;
final scoreTitle = hasStopwatchScore
? 'Score chrono'
: scoreUnit == null
? 'Score'
: 'Score ($scoreUnit)';
final autoStartNextTimedStepCustomized =
_draft.autoStartNextTimedStepOverride != null;
final autoStartNextTimedStepStateLabel = autoStartNextTimedStepCustomized
? 'Personnalisé pour ce programme'
: "Réglage de l'exercice";
return Scaffold(
appBar: AppBar(title: const Text("Personnaliser l'exercice")),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _confirmDelete,
icon: const Icon(Icons.delete_outline),
label: const Text('Supprimer du programme'),
),
],
),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
_draft.exerciseNameSnapshot,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 4),
Text(_exerciseSummary(_draft, widget.defaultRestSeconds)),
const SizedBox(height: 24),
Text('Séries', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextFormField(
controller: _setsController,
decoration: const InputDecoration(labelText: 'Nombre de séries'),
keyboardType: TextInputType.number,
validator: _positiveIntValidator,
),
const SizedBox(height: 24),
Text(
'Mesures à suivre',
style: Theme.of(context).textTheme.titleMedium,
),
if (_draft.availableTimeSnapshot)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: _draft.enabledMeasures.contains(WorkoutMeasure.time),
onChanged: (value) => _toggle(WorkoutMeasure.time, value),
),
if (_draft.availableRepsSnapshot)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: _draft.enabledMeasures.contains(WorkoutMeasure.reps),
onChanged: (value) => _toggle(WorkoutMeasure.reps, value),
),
if (_draft.availableScoreSnapshot)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(scoreTitle),
value: _draft.enabledMeasures.contains(WorkoutMeasure.score),
onChanged: (value) => _toggle(WorkoutMeasure.score, value),
),
if (_draft.enabledMeasures.contains(WorkoutMeasure.time) &&
_draft.enabledMeasures.contains(WorkoutMeasure.score) &&
hasStopwatchScore) ...[
const SizedBox(height: 8),
const Text(
"Temps sert d'objectif de durée ; Score chrono enregistre "
'le temps réalisé.',
),
],
if (_measureError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_measureError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 24),
Text('Objectifs', style: Theme.of(context).textTheme.titleMedium),
if (_draft.enabledMeasures.contains(WorkoutMeasure.time))
_NumberField(
label: 'Cible temps (s)',
initialValue: _draft.targetTimeSeconds,
onChanged: (value) => _draft.targetTimeSeconds = value?.round(),
),
if (_draft.enabledMeasures.contains(WorkoutMeasure.reps))
_NumberField(
label: 'Cible répétitions',
initialValue: _draft.targetReps,
onChanged: (value) => _draft.targetReps = value?.round(),
),
if (_draft.enabledMeasures.contains(WorkoutMeasure.score))
if (hasStopwatchScore)
_NumberField(
label: 'Objectif chrono (optionnel)',
helperText: 'Le résultat réel sera mesuré pendant la série.',
initialValue: _millisecondsToSeconds(
_draft.targetScoreTimeMs,
),
onChanged: (value) {
_draft.targetScore = null;
_draft.targetScoreTimeMs = value == null
? null
: (value * 1000).round();
},
)
else
_NumberField(
label: scoreUnit == null
? 'Cible score'
: 'Cible score ($scoreUnit)',
initialValue: _draft.targetScore,
onChanged: (value) {
_draft.targetScore = value;
_draft.targetScoreTimeMs = null;
},
),
if (_draft.exerciseStepsSnapshot.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Séquence', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'Enchaîner automatiquement les chronos consécutifs',
),
subtitle: Text(
'$autoStartNextTimedStepStateLabel\n'
'${_autoStartHelpText(_draft.autoStartNextTimedStepEffective)}',
),
value: _draft.autoStartNextTimedStepEffective,
onChanged: (value) {
setState(() {
_draft.autoStartNextTimedStepOverride = value;
});
},
),
if (autoStartNextTimedStepCustomized)
TextButton(
onPressed: () {
setState(() {
_draft.autoStartNextTimedStepOverride = null;
});
},
child: const Text("Revenir au réglage de l'exercice"),
),
],
const SizedBox(height: 24),
Text('Repos', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextFormField(
controller: _restController,
decoration: const InputDecoration(
labelText: 'Repos après chaque série (s)',
helperText: 'Pas de repos après la toute dernière série.',
),
keyboardType: TextInputType.number,
validator: _positiveIntValidator,
),
],
),
),
);
}
void _toggle(WorkoutMeasure measure, bool value) {
setState(() {
_measureError = null;
if (value) {
_draft.enabledMeasures = {..._draft.enabledMeasures, measure};
} else {
_draft.enabledMeasures = {..._draft.enabledMeasures}..remove(measure);
if (measure == WorkoutMeasure.score) {
_draft.targetScore = null;
_draft.targetScoreTimeMs = null;
}
}
});
}
String? _positiveIntValidator(String? value) {
final number = int.tryParse(value?.trim() ?? '');
if (number == null || number <= 0) {
return 'Saisis un nombre supérieur à 0.';
}
return null;
}
void _save() {
final hasMeasure = _draft.enabledMeasures.isNotEmpty;
setState(() {
_measureError = hasMeasure
? null
: 'Active au moins une mesure pour enregistrer.';
});
if (!_formKey.currentState!.validate() || !hasMeasure) {
return;
}
_draft.setsCount = int.parse(_setsController.text.trim());
_draft.restSecondsOverride = int.parse(_restController.text.trim());
Navigator.of(context).pop(_ProgramExerciseEditResult.saved(_draft));
}
Future<void> _confirmDelete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer cet exercice ?'),
content: const Text('Il sera retiré de ce programme.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed == true && mounted) {
Navigator.of(context).pop(const _ProgramExerciseEditResult.deleted());
}
}
}
final class _ProgramExerciseEditResult {
const _ProgramExerciseEditResult._({this.draft, required this.deleted});
const _ProgramExerciseEditResult.deleted()
: this._(draft: null, deleted: true);
const _ProgramExerciseEditResult.saved(_ProgramExerciseDraft draft)
: this._(draft: draft, deleted: false);
final _ProgramExerciseDraft? draft;
final bool deleted;
}
final class _NumberField extends StatelessWidget {
const _NumberField({
required this.label,
required this.initialValue,
required this.onChanged,
this.helperText,
});
final String label;
final num? initialValue;
final ValueChanged<double?> onChanged;
final String? helperText;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
initialValue: initialValue?.toString(),
decoration: InputDecoration(labelText: label, helperText: helperText),
keyboardType: TextInputType.number,
onChanged: (value) => onChanged(double.tryParse(value)),
),
);
}
}
final class _ProgramExerciseDraft {
_ProgramExerciseDraft({
required this.key,
this.existingMetadata,
this.sourceExerciseId,
required this.exerciseNameSnapshot,
this.exerciseDescriptionSnapshot,
this.exerciseImageMediaIdSnapshot,
this.exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
this.exerciseStepsSnapshot = const [],
this.autoStartNextTimedStepSnapshot = true,
this.autoStartNextTimedStepOverride,
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
required this.availableScoreSnapshot,
this.scoreInputModeSnapshot = ScoreInputMode.manual,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
required this.enabledMeasures,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
this.restSecondsOverride,
});
factory _ProgramExerciseDraft.fromExercise(
Exercise exercise, {
int? restSecondsOverride,
}) {
return _ProgramExerciseDraft(
key: UniqueKey().toString(),
sourceExerciseId: exercise.metadata.id,
exerciseNameSnapshot: exercise.name,
exerciseDescriptionSnapshot: exercise.description,
exerciseImageMediaIdSnapshot:
exercise.iconMediaId ?? exercise.imageMediaId,
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseStepsSnapshot: exercise.steps,
autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStep,
exerciseArchivedSnapshot: exercise.archivedAt != null,
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
availableScoreSnapshot: exercise.hasScoreMeasure,
scoreInputModeSnapshot: exercise.scoreInputMode,
scoreLabelSnapshot: exercise.scoreLabel,
scoreUnitSnapshot: exercise.scoreUnit,
setsCount: 3,
enabledMeasures: exercise.availableMeasures,
targetTimeSeconds: exercise.defaultTargetTimeSeconds,
targetReps: exercise.defaultTargetReps,
targetScore: exercise.scoreInputMode == ScoreInputMode.manual
? exercise.defaultTargetScore
: null,
targetScoreTimeMs: exercise.scoreInputMode == ScoreInputMode.stopwatch
? exercise.defaultTargetScoreTimeMs
: null,
restSecondsOverride: restSecondsOverride,
);
}
factory _ProgramExerciseDraft.fromProgramExercise(ProgramExercise exercise) {
return _ProgramExerciseDraft(
key: exercise.metadata.id,
existingMetadata: exercise.metadata,
sourceExerciseId: exercise.sourceExerciseId,
exerciseNameSnapshot: exercise.exerciseNameSnapshot,
exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exercise.exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: exercise.autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot,
availableTimeSnapshot: exercise.availableTimeSnapshot,
availableRepsSnapshot: exercise.availableRepsSnapshot,
availableScoreSnapshot: exercise.availableScoreSnapshot,
scoreInputModeSnapshot: exercise.scoreInputModeSnapshot,
scoreLabelSnapshot: exercise.scoreLabelSnapshot,
scoreUnitSnapshot: exercise.scoreUnitSnapshot,
setsCount: exercise.setsCount,
enabledMeasures: {
if (exercise.timeEnabled) WorkoutMeasure.time,
if (exercise.repsEnabled) WorkoutMeasure.reps,
if (exercise.scoreEnabled) WorkoutMeasure.score,
},
targetTimeSeconds: exercise.targetTimeSeconds,
targetReps: exercise.targetReps,
targetScore: exercise.targetScore,
targetScoreTimeMs: exercise.targetScoreTimeMs,
restSecondsOverride: exercise.restSecondsOverride,
);
}
final String key;
final EntityMetadata? existingMetadata;
final String? sourceExerciseId;
final String exerciseNameSnapshot;
final String? exerciseDescriptionSnapshot;
final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool autoStartNextTimedStepSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
final bool availableScoreSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
int setsCount;
Set<WorkoutMeasure> enabledMeasures;
int? targetTimeSeconds;
int? targetReps;
double? targetScore;
int? targetScoreTimeMs;
int? restSecondsOverride;
bool? autoStartNextTimedStepOverride;
bool get autoStartNextTimedStepEffective {
return autoStartNextTimedStepOverride ?? autoStartNextTimedStepSnapshot;
}
_ProgramExerciseDraft copy() {
return _ProgramExerciseDraft(
key: key,
existingMetadata: existingMetadata,
sourceExerciseId: sourceExerciseId,
exerciseNameSnapshot: exerciseNameSnapshot,
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
availableScoreSnapshot: availableScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
setsCount: setsCount,
enabledMeasures: {...enabledMeasures},
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
restSecondsOverride: restSecondsOverride,
);
}
ProgramExerciseConfig toConfig() {
final scoreEnabled = enabledMeasures.contains(WorkoutMeasure.score);
final stopwatchScore = scoreInputModeSnapshot == ScoreInputMode.stopwatch;
return ProgramExerciseConfig(
existingMetadata: existingMetadata,
sourceExerciseId: sourceExerciseId,
exerciseNameSnapshot: exerciseNameSnapshot,
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
availableScoreSnapshot: availableScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
setsCount: setsCount,
enabledMeasures: enabledMeasures,
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: scoreEnabled && !stopwatchScore ? targetScore : null,
targetScoreTimeMs: scoreEnabled && stopwatchScore
? targetScoreTimeMs
: null,
restSecondsOverride: restSecondsOverride,
);
}
}
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.";
}
double? _millisecondsToSeconds(int? milliseconds) {
return milliseconds == null ? null : milliseconds / 1000;
}
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 _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) {
final measures = draft.enabledMeasures
.map((measure) {
final unit = draft.scoreUnitSnapshot?.trim();
if (measure == WorkoutMeasure.score &&
draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch) {
return 'Score chrono';
}
if (measure == WorkoutMeasure.score &&
unit != null &&
unit.isNotEmpty) {
return 'Score ($unit)';
}
return _measureLabel(measure);
})
.join(' + ');
final rest = draft.restSecondsOverride ?? defaultRestSeconds;
return '${draft.setsCount} séries · $measures · Repos $rest s';
}
String _restLabel(_ProgramExerciseDraft draft, int defaultRestSeconds) {
final rest = draft.restSecondsOverride ?? defaultRestSeconds;
return 'Repos $rest s';
}
String _measureLabel(WorkoutMeasure measure) {
return switch (measure) {
WorkoutMeasure.time => 'Temps',
WorkoutMeasure.reps => 'Répétitions',
WorkoutMeasure.score => 'Score',
};
}
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!)),
],
],
),
),
);
}
}