1201 lines
40 KiB
Dart
1201 lines
40 KiB
Dart
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.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 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 '
|
||
'd’en 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,
|
||
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!)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|