Files
GameTime/lib/presentation/workout_template_screen.dart
Blomios d16a017743 feat(online): partage sortant et boîte de réception (ticket #69)
Ajoute l'écran de partage sortant (presentation/share_screen.dart) et
la boîte de réception des partages (presentation/share_inbox_screen.dart),
avec les points d'entrée sur profile_screen.dart, home_screen.dart,
program_screen.dart et workout_template_screen.dart. flutter analyze
propre (mêmes infos préexistantes), 132/132 tests verts, build APK
debug validé. Dernier ticket de développement du chantier "Ajouter
les features serveur au client" (#63) — il ne reste que la QA finale
(#70).

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

847 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import 'dart:convert';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.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.shareUseCases,
this.authUseCases,
this.syncUseCases,
super.key,
});
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final MediaUseCases? mediaUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
@override
State<WorkoutTemplateListScreen> createState() =>
_WorkoutTemplateListScreenState();
}
final class _WorkoutTemplateListScreenState
extends State<WorkoutTemplateListScreen> {
late Future<List<WorkoutTemplate>> _templates;
@override
void initState() {
super.initState();
_templates = widget.workoutTemplateUseCases.listActive();
}
@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>[];
if (templates.isEmpty) {
return _CenteredMessage(
title: 'Aucune séance pour le moment',
message: 'Compose une séance à partir de tes programmes.',
actionLabel: 'Créer une séance',
onAction: () => _openForm(),
);
}
return ListView.separated(
itemCount: templates.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final template = templates[index];
return ListTile(
title: Text(template.name),
subtitle: Text(_templateSummary(template)),
trailing: Wrap(
spacing: 8,
children: [
IconButton(
tooltip: 'Supprimer la séance',
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDelete(template),
),
TextButton(
onPressed: () => _start(template),
child: const Text('Lancer'),
),
const Icon(Icons.chevron_right),
],
),
onTap: () => _openForm(template: template),
);
},
);
},
),
);
}
void _reload() {
setState(() {
_templates = widget.workoutTemplateUseCases.listActive();
});
}
Future<void> _openForm({WorkoutTemplate? template}) async {
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,
),
),
);
if (changed == true) {
if (!mounted) return;
_reload();
}
}
Future<void> _start(WorkoutTemplate template) async {
final openSession = await widget.activeUseCases.findOpen();
if (!mounted) return;
if (openSession != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
'den lancer une nouvelle.',
),
),
);
return;
}
final session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
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,
mediaUseCases: widget.mediaUseCases,
),
),
);
}
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)));
}
}
final class WorkoutTemplateFormScreen extends StatefulWidget {
const WorkoutTemplateFormScreen({
required this.workoutTemplateUseCases,
required this.programUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
this.template,
super.key,
});
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ProgramUseCases programUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
final WorkoutTemplate? template;
@override
State<WorkoutTemplateFormScreen> createState() =>
_WorkoutTemplateFormScreenState();
}
final class _WorkoutTemplateFormScreenState
extends State<WorkoutTemplateFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final List<WorkoutTemplateProgramDraft> _programs;
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>[],
),
];
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
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: 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'),
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
: null,
),
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 un programme pour composer la séance.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _programs.length,
onReorder: _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(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
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 (!_formKey.currentState!.validate()) {
return;
}
setState(() => _saving = true);
try {
await widget.workoutTemplateUseCases.saveConfigured(
id: widget.template?.metadata.id,
name: _nameController.text.trim(),
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];
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;
});
},
),
],
),
),
);
},
),
);
}
}
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;
}
}
}
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,
);
}
}
}
final class WorkoutTemplateExerciseDraft {
WorkoutTemplateExerciseDraft({
required this.snapshotExerciseId,
required this.exerciseNameSnapshot,
required this.setsCount,
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
this.scoreUnitSnapshot,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.existingOverrideMetadata,
this.setsCountOverride,
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
});
final String snapshotExerciseId;
final String exerciseNameSnapshot;
final int setsCount;
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final String? scoreUnitSnapshot;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
EntityMetadata? existingOverrideMetadata;
int? setsCountOverride;
int? targetTimeSecondsOverride;
int? targetRepsOverride;
double? targetScoreOverride;
bool get hasOverride {
return setsCountOverride != null ||
targetTimeSecondsOverride != null ||
targetRepsOverride != null ||
targetScoreOverride != 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>;
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,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
);
}).toList();
}
String _templateSummary(WorkoutTemplate template) {
final programCount = template.programs.length;
final exerciseCount = template.programs.fold<int>(
0,
(total, program) => total + _exerciseCount(program.programSnapshotJson),
);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
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) {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
}
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!)),
],
],
),
),
);
}
}