feat(ui): écran Création/édition de programme
Ajoute l'écran de programme (presentation/program_screen.dart) et un écran d'accueil (presentation/home_screen.dart) pour la navigation, avec les ajustements application/domaine/repositories nécessaires. flutter analyze propre, 11/11 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
772
lib/presentation/program_screen.dart
Normal file
772
lib/presentation/program_screen.dart
Normal file
@ -0,0 +1,772 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'exercise_library_screen.dart';
|
||||
|
||||
final class ProgramListScreen extends StatefulWidget {
|
||||
const ProgramListScreen({
|
||||
required this.programUseCases,
|
||||
required this.exerciseUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProgramUseCases programUseCases;
|
||||
final ExerciseUseCases exerciseUseCases;
|
||||
|
||||
@override
|
||||
State<ProgramListScreen> createState() => _ProgramListScreenState();
|
||||
}
|
||||
|
||||
final class _ProgramListScreenState extends State<ProgramListScreen> {
|
||||
late Future<List<Program>> _programs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_programs = widget.programUseCases.listActive();
|
||||
}
|
||||
|
||||
@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>[];
|
||||
if (programs.isEmpty) {
|
||||
return _CenteredMessage(
|
||||
title: 'Aucun programme pour le moment',
|
||||
message:
|
||||
'Crée un programme pour organiser tes exercices en séries.',
|
||||
actionLabel: 'Créer un programme',
|
||||
onAction: () => _openForm(),
|
||||
);
|
||||
}
|
||||
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)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _openForm(program: program),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() {
|
||||
_programs = widget.programUseCases.listActive();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm({Program? program}) async {
|
||||
final changed = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProgramFormScreen(
|
||||
programUseCases: widget.programUseCases,
|
||||
exerciseUseCases: widget.exerciseUseCases,
|
||||
program: program,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (changed == true) {
|
||||
if (!mounted) return;
|
||||
_reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ProgramFormScreen extends StatefulWidget {
|
||||
const ProgramFormScreen({
|
||||
required this.programUseCases,
|
||||
required this.exerciseUseCases,
|
||||
this.program,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProgramUseCases programUseCases;
|
||||
final ExerciseUseCases exerciseUseCases;
|
||||
final Program? program;
|
||||
|
||||
@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;
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_defaultRestController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_isEditing ? 'Modifier le programme' : 'Créer un programme',
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
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',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
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)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: _nonNegativeIntValidator,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
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 un exercice depuis la bibliothèque.',
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _exercises.length,
|
||||
onReorder: _reorderExercise,
|
||||
itemBuilder: (context, index) {
|
||||
final exercise = _exercises[index];
|
||||
return _ProgramExerciseCard(
|
||||
key: ValueKey(exercise.key),
|
||||
draft: exercise,
|
||||
defaultRestSeconds: _defaultRestSeconds,
|
||||
onChanged: () => setState(() {}),
|
||||
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;
|
||||
setState(() {
|
||||
_exercises.add(_ProgramExerciseDraft.fromExercise(exercise));
|
||||
});
|
||||
}
|
||||
|
||||
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 (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.programUseCases.saveConfigured(
|
||||
id: widget.program?.metadata.id,
|
||||
name: _nameController.text.trim(),
|
||||
defaultRestSeconds: _defaultRestSeconds,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
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.onChanged,
|
||||
required this.index,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final _ProgramExerciseDraft draft;
|
||||
final int defaultRestSeconds;
|
||||
final VoidCallback onChanged;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scoreUnit = draft.scoreUnitSnapshot;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(Icons.drag_handle),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
draft.exerciseNameSnapshot,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (draft.exerciseArchivedSnapshot)
|
||||
const Chip(label: Text('Exercice archivé')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(_exerciseSummary(draft, defaultRestSeconds)),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
initialValue: '${draft.setsCount}',
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nombre de séries',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
draft.setsCount = int.tryParse(value) ?? draft.setsCount;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Mesures à suivre',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
if (draft.availableTimeSnapshot)
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Temps'),
|
||||
value: draft.enabledMeasures.contains(WorkoutMeasure.time),
|
||||
onChanged: (value) => _toggle(WorkoutMeasure.time, value),
|
||||
),
|
||||
if (draft.availableRepsSnapshot)
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Répétitions'),
|
||||
value: draft.enabledMeasures.contains(WorkoutMeasure.reps),
|
||||
onChanged: (value) => _toggle(WorkoutMeasure.reps, value),
|
||||
),
|
||||
if (draft.availableScoreSnapshot)
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'),
|
||||
value: draft.enabledMeasures.contains(WorkoutMeasure.score),
|
||||
onChanged: (value) => _toggle(WorkoutMeasure.score, value),
|
||||
),
|
||||
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))
|
||||
_NumberField(
|
||||
label: scoreUnit == null
|
||||
? 'Cible score'
|
||||
: 'Cible score ($scoreUnit)',
|
||||
initialValue: draft.targetScore,
|
||||
onChanged: (value) => draft.targetScore = value,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
initialValue:
|
||||
'${draft.restSecondsOverride ?? defaultRestSeconds}',
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Repos après chaque série (s)',
|
||||
helperText: 'Pas de repos après la toute dernière série.',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
draft.restSecondsOverride = int.tryParse(value);
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggle(WorkoutMeasure measure, bool? value) {
|
||||
if (value == true) {
|
||||
draft.enabledMeasures = {...draft.enabledMeasures, measure};
|
||||
} else if (draft.enabledMeasures.length > 1) {
|
||||
draft.enabledMeasures = {...draft.enabledMeasures}..remove(measure);
|
||||
}
|
||||
onChanged();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
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.exerciseVideoMediaIdSnapshot,
|
||||
this.exerciseArchivedSnapshot = false,
|
||||
required this.availableTimeSnapshot,
|
||||
required this.availableRepsSnapshot,
|
||||
required this.availableScoreSnapshot,
|
||||
this.scoreLabelSnapshot,
|
||||
this.scoreUnitSnapshot,
|
||||
required this.setsCount,
|
||||
required this.enabledMeasures,
|
||||
this.targetTimeSeconds,
|
||||
this.targetReps,
|
||||
this.targetScore,
|
||||
this.restSecondsOverride,
|
||||
});
|
||||
|
||||
factory _ProgramExerciseDraft.fromExercise(Exercise exercise) {
|
||||
return _ProgramExerciseDraft(
|
||||
key: UniqueKey().toString(),
|
||||
sourceExerciseId: exercise.metadata.id,
|
||||
exerciseNameSnapshot: exercise.name,
|
||||
exerciseDescriptionSnapshot: exercise.description,
|
||||
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
|
||||
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
|
||||
exerciseArchivedSnapshot: exercise.archivedAt != null,
|
||||
availableTimeSnapshot: exercise.hasTimeMeasure,
|
||||
availableRepsSnapshot: exercise.hasRepsMeasure,
|
||||
availableScoreSnapshot: exercise.hasScoreMeasure,
|
||||
scoreLabelSnapshot: exercise.scoreLabel,
|
||||
scoreUnitSnapshot: exercise.scoreUnit,
|
||||
setsCount: 3,
|
||||
enabledMeasures: exercise.availableMeasures,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot,
|
||||
exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot,
|
||||
availableTimeSnapshot: exercise.availableTimeSnapshot,
|
||||
availableRepsSnapshot: exercise.availableRepsSnapshot,
|
||||
availableScoreSnapshot: exercise.availableScoreSnapshot,
|
||||
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,
|
||||
restSecondsOverride: exercise.restSecondsOverride,
|
||||
);
|
||||
}
|
||||
|
||||
final String key;
|
||||
final EntityMetadata? existingMetadata;
|
||||
final String? sourceExerciseId;
|
||||
final String exerciseNameSnapshot;
|
||||
final String? exerciseDescriptionSnapshot;
|
||||
final String? exerciseImageMediaIdSnapshot;
|
||||
final String? exerciseVideoMediaIdSnapshot;
|
||||
final bool exerciseArchivedSnapshot;
|
||||
final bool availableTimeSnapshot;
|
||||
final bool availableRepsSnapshot;
|
||||
final bool availableScoreSnapshot;
|
||||
final String? scoreLabelSnapshot;
|
||||
final String? scoreUnitSnapshot;
|
||||
int setsCount;
|
||||
Set<WorkoutMeasure> enabledMeasures;
|
||||
int? targetTimeSeconds;
|
||||
int? targetReps;
|
||||
double? targetScore;
|
||||
int? restSecondsOverride;
|
||||
|
||||
ProgramExerciseConfig toConfig() {
|
||||
return ProgramExerciseConfig(
|
||||
existingMetadata: existingMetadata,
|
||||
sourceExerciseId: sourceExerciseId,
|
||||
exerciseNameSnapshot: exerciseNameSnapshot,
|
||||
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
|
||||
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
|
||||
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
|
||||
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
|
||||
availableTimeSnapshot: availableTimeSnapshot,
|
||||
availableRepsSnapshot: availableRepsSnapshot,
|
||||
availableScoreSnapshot: availableScoreSnapshot,
|
||||
scoreLabelSnapshot: scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: scoreUnitSnapshot,
|
||||
setsCount: setsCount,
|
||||
enabledMeasures: enabledMeasures,
|
||||
targetTimeSeconds: targetTimeSeconds,
|
||||
targetReps: targetReps,
|
||||
targetScore: targetScore,
|
||||
restSecondsOverride: restSecondsOverride,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 &&
|
||||
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 _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: Padding(
|
||||
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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user