From 89d89a30054be09ba2c7bdf9d8be330e151afcdc Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 18:59:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=C3=A9cran=20Composition=20de=20s?= =?UTF-8?q?=C3=A9ance-mod=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute l'écran de composition de séance-modèle (presentation/workout_template_screen.dart), avec les ajustements application/domaine/repositories nécessaires et la navigation depuis l'accueil. flutter analyze propre, 12/12 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/application/app_bootstrap.dart | 10 + lib/application/ports.dart | 2 + lib/application/use_cases.dart | 118 +++ .../local/drift_repositories.dart | 118 ++- lib/presentation/home_screen.dart | 14 + lib/presentation/presentation.dart | 1 + lib/presentation/workout_template_screen.dart | 722 ++++++++++++++++++ test/application/use_cases_test.dart | 9 + .../workout_template_screen_test.dart | 42 + 9 files changed, 1029 insertions(+), 7 deletions(-) create mode 100644 lib/presentation/workout_template_screen.dart create mode 100644 test/presentation/workout_template_screen_test.dart diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index c4b28ea..2422369 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -7,12 +7,14 @@ final class AppBootstrap { required this.exerciseUseCases, required this.mediaUseCases, required this.programUseCases, + required this.workoutTemplateUseCases, }); final AppDatabase database; final ExerciseUseCases exerciseUseCases; final MediaUseCases mediaUseCases; final ProgramUseCases programUseCases; + final WorkoutTemplateUseCases workoutTemplateUseCases; static Future create() async { final database = AppDatabase.open(); @@ -20,6 +22,7 @@ final class AppBootstrap { final exerciseRepository = DriftExerciseRepository(database); final mediaRepository = DriftMediaAssetRepository(database); final programRepository = DriftProgramRepository(database); + final templateRepository = DriftWorkoutTemplateRepository(database); final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; @@ -47,6 +50,13 @@ final class AppBootstrap { ids: ids, originDeviceId: originDeviceId, ), + workoutTemplateUseCases: WorkoutTemplateUseCases( + templateRepository: templateRepository, + programRepository: programRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ), ); } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 0930fda..0085593 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -67,9 +67,11 @@ abstract interface class ProgramRepository { abstract interface class WorkoutTemplateRepository { Future findById(String id); + Future> listActive(); Future save(WorkoutTemplate template); Future saveProgram(WorkoutTemplateProgram program); Future saveOverride(WorkoutTemplateExerciseOverride override); + Future replaceComposition(WorkoutTemplate template, DateTime deletedAt); } abstract interface class ActiveSessionRepository { diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 3f34af2..df3630c 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -398,6 +398,14 @@ final class WorkoutTemplateUseCases { final IdGenerator ids; final String originDeviceId; + Future findById(String id) { + return templateRepository.findById(id); + } + + Future> listActive() { + return templateRepository.listActive(); + } + Future create({required String name}) async { final now = clock.now(); final template = WorkoutTemplate( @@ -408,6 +416,78 @@ final class WorkoutTemplateUseCases { return template; } + Future saveConfigured({ + String? id, + required String name, + required List programs, + required List overrides, + }) async { + final now = clock.now(); + final existing = id == null ? null : await templateRepository.findById(id); + if (id != null && existing == null) { + throw const DomainException('Workout template not found.'); + } + final templateId = id ?? ids.newId(); + final metadata = + existing?.metadata.touch(now) ?? + EntityMetadata( + id: templateId, + createdAt: now, + updatedAt: now, + originDeviceId: originDeviceId, + ); + final templatePrograms = []; + final programIdsByClientKey = {}; + for (var index = 0; index < programs.length; index++) { + final input = programs[index]; + final programMetadata = + input.existingMetadata?.touch(now) ?? + _newMetadata(ids, originDeviceId, now); + programIdsByClientKey[input.clientKey] = programMetadata.id; + templatePrograms.add( + WorkoutTemplateProgram( + metadata: programMetadata, + workoutTemplateId: templateId, + sourceProgramId: input.sourceProgramId, + position: index, + programNameSnapshot: input.programNameSnapshot, + defaultRestSecondsSnapshot: input.defaultRestSecondsSnapshot, + programSnapshotJson: input.programSnapshotJson, + ), + ); + } + final templateOverrides = []; + for (final input in overrides) { + final workoutTemplateProgramId = + programIdsByClientKey[input.workoutTemplateProgramClientKey]; + if (workoutTemplateProgramId == null) { + throw const DomainException('Workout template program not found.'); + } + templateOverrides.add( + WorkoutTemplateExerciseOverride( + metadata: + input.existingMetadata?.touch(now) ?? + _newMetadata(ids, originDeviceId, now), + workoutTemplateProgramId: workoutTemplateProgramId, + snapshotProgramExerciseId: input.snapshotProgramExerciseId, + setsCountOverride: input.setsCountOverride, + targetTimeSecondsOverride: input.targetTimeSecondsOverride, + targetRepsOverride: input.targetRepsOverride, + targetScoreOverride: input.targetScoreOverride, + ), + ); + } + final template = WorkoutTemplate( + metadata: metadata, + name: name, + lastStartedAt: existing?.lastStartedAt, + programs: templatePrograms, + overrides: templateOverrides, + ); + await templateRepository.replaceComposition(template, now); + return template; + } + Future addProgramSnapshot({ required String workoutTemplateId, required String programId, @@ -457,6 +537,44 @@ final class WorkoutTemplateUseCases { } } +final class WorkoutTemplateProgramConfig { + const WorkoutTemplateProgramConfig({ + required this.clientKey, + this.existingMetadata, + this.sourceProgramId, + required this.programNameSnapshot, + required this.defaultRestSecondsSnapshot, + required this.programSnapshotJson, + }); + + final String clientKey; + final EntityMetadata? existingMetadata; + final String? sourceProgramId; + final String programNameSnapshot; + final int defaultRestSecondsSnapshot; + final String programSnapshotJson; +} + +final class WorkoutTemplateExerciseOverrideConfig { + const WorkoutTemplateExerciseOverrideConfig({ + this.existingMetadata, + required this.workoutTemplateProgramClientKey, + required this.snapshotProgramExerciseId, + this.setsCountOverride, + this.targetTimeSecondsOverride, + this.targetRepsOverride, + this.targetScoreOverride, + }); + + final EntityMetadata? existingMetadata; + final String workoutTemplateProgramClientKey; + final String snapshotProgramExerciseId; + final int? setsCountOverride; + final int? targetTimeSecondsOverride; + final int? targetRepsOverride; + final double? targetScoreOverride; +} + final class ActiveWorkoutSessionUseCases { const ActiveWorkoutSessionUseCases({ required this.sessionRepository, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 559f455..7e7d5b7 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -191,21 +191,33 @@ final class DriftWorkoutTemplateRepository @override Future findById(String id) async { - final row = await (database.select( - database.workoutTemplates, - )..where((table) => table.id.equals(id))).getSingleOrNull(); + final row = + await (database.select( + database.workoutTemplates, + )..where((table) => table.id.equals(id) & table.deletedAt.isNull())) + .getSingleOrNull(); if (row == null) { return null; } final programs = await (database.select(database.workoutTemplatePrograms) - ..where((table) => table.workoutTemplateId.equals(id)) + ..where( + (table) => + table.workoutTemplateId.equals(id) & + table.deletedAt.isNull(), + ) ..orderBy([(table) => OrderingTerm.asc(table.position)])) .get(); final programIds = programs.map((program) => program.id).toSet(); - final overrides = await (database.select( - database.workoutTemplateExerciseOverrides, - )..where((table) => table.workoutTemplateProgramId.isIn(programIds))).get(); + final overrides = programIds.isEmpty + ? [] + : await (database.select(database.workoutTemplateExerciseOverrides) + ..where( + (table) => + table.workoutTemplateProgramId.isIn(programIds) & + table.deletedAt.isNull(), + )) + .get(); return _workoutTemplateFromRow( row, programs.map(_workoutTemplateProgramFromRow).toList(), @@ -213,6 +225,23 @@ final class DriftWorkoutTemplateRepository ); } + @override + Future> listActive() async { + final rows = + await (database.select(database.workoutTemplates) + ..where((table) => table.deletedAt.isNull()) + ..orderBy([(table) => OrderingTerm.asc(table.name)])) + .get(); + final templates = []; + for (final row in rows) { + final template = await findById(row.id); + if (template != null) { + templates.add(template); + } + } + return templates; + } + @override Future save(domain.WorkoutTemplate template) async { await database @@ -243,6 +272,81 @@ final class DriftWorkoutTemplateRepository _workoutTemplateExerciseOverrideCompanion(override), ); } + + @override + Future replaceComposition( + domain.WorkoutTemplate template, + DateTime deletedAt, + ) async { + await database.transaction(() async { + final previousPrograms = + await (database.select(database.workoutTemplatePrograms)..where( + (table) => + table.workoutTemplateId.equals(template.metadata.id) & + table.deletedAt.isNull(), + )) + .get(); + final previousProgramIds = previousPrograms + .map((program) => program.id) + .toSet(); + await save(template); + final activeProgramIds = template.programs + .map((program) => program.metadata.id) + .toSet(); + final programUpdate = database.update(database.workoutTemplatePrograms); + if (activeProgramIds.isEmpty) { + programUpdate.where( + (table) => + table.workoutTemplateId.equals(template.metadata.id) & + table.deletedAt.isNull(), + ); + } else { + programUpdate.where( + (table) => + table.workoutTemplateId.equals(template.metadata.id) & + table.deletedAt.isNull() & + table.id.isNotIn(activeProgramIds), + ); + } + await programUpdate.write( + db.WorkoutTemplateProgramsCompanion( + deletedAt: Value(deletedAt), + updatedAt: Value(deletedAt), + ), + ); + + final activeOverrideIds = template.overrides + .map((override) => override.metadata.id) + .toSet(); + final relevantProgramIds = {...previousProgramIds, ...activeProgramIds}; + if (relevantProgramIds.isEmpty) { + return; + } + final overrideUpdate = database.update( + database.workoutTemplateExerciseOverrides, + ); + if (activeOverrideIds.isEmpty) { + overrideUpdate.where( + (table) => + table.workoutTemplateProgramId.isIn(relevantProgramIds) & + table.deletedAt.isNull(), + ); + } else { + overrideUpdate.where( + (table) => + table.workoutTemplateProgramId.isIn(relevantProgramIds) & + table.deletedAt.isNull() & + table.id.isNotIn(activeOverrideIds), + ); + } + await overrideUpdate.write( + db.WorkoutTemplateExerciseOverridesCompanion( + deletedAt: Value(deletedAt), + updatedAt: Value(deletedAt), + ), + ); + }); + } } final class DriftActiveSessionRepository implements ActiveSessionRepository { diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 5091ec0..15051c3 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../application/app_bootstrap.dart'; import 'exercise_library_screen.dart'; import 'program_screen.dart'; +import 'workout_template_screen.dart'; final class HomeScreen extends StatelessWidget { const HomeScreen({required this.bootstrap, super.key}); @@ -41,6 +42,19 @@ final class HomeScreen extends StatelessWidget { ), ), ), + ListTile( + leading: const Icon(Icons.event_note), + title: const Text('Séances'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => WorkoutTemplateListScreen( + workoutTemplateUseCases: bootstrap.workoutTemplateUseCases, + programUseCases: bootstrap.programUseCases, + ), + ), + ), + ), ], ), ); diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index df8a784..955b583 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -2,3 +2,4 @@ export 'game_time_app.dart'; export 'exercise_library_screen.dart'; export 'home_screen.dart'; export 'program_screen.dart'; +export 'workout_template_screen.dart'; diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart new file mode 100644 index 0000000..ebbf0f8 --- /dev/null +++ b/lib/presentation/workout_template_screen.dart @@ -0,0 +1,722 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../application/application.dart'; +import '../domain/domain.dart'; + +final class WorkoutTemplateListScreen extends StatefulWidget { + const WorkoutTemplateListScreen({ + required this.workoutTemplateUseCases, + required this.programUseCases, + super.key, + }); + + final WorkoutTemplateUseCases workoutTemplateUseCases; + final ProgramUseCases programUseCases; + + @override + State createState() => + _WorkoutTemplateListScreenState(); +} + +final class _WorkoutTemplateListScreenState + extends State { + late Future> _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>( + future: _templates, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final templates = snapshot.data ?? const []; + 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: [ + TextButton(onPressed: () {}, child: const Text('Lancer')), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _openForm(template: template), + ); + }, + ); + }, + ), + ); + } + + void _reload() { + setState(() { + _templates = widget.workoutTemplateUseCases.listActive(); + }); + } + + Future _openForm({WorkoutTemplate? template}) async { + final changed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => WorkoutTemplateFormScreen( + workoutTemplateUseCases: widget.workoutTemplateUseCases, + programUseCases: widget.programUseCases, + template: template, + ), + ), + ); + if (changed == true) { + if (!mounted) return; + _reload(); + } + } +} + +final class WorkoutTemplateFormScreen extends StatefulWidget { + const WorkoutTemplateFormScreen({ + required this.workoutTemplateUseCases, + required this.programUseCases, + this.template, + super.key, + }); + + final WorkoutTemplateUseCases workoutTemplateUseCases; + final ProgramUseCases programUseCases; + final WorkoutTemplate? template; + + @override + State createState() => + _WorkoutTemplateFormScreenState(); +} + +final class _WorkoutTemplateFormScreenState + extends State { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final List _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 []) + WorkoutTemplateProgramDraft.fromTemplateProgram( + program, + template?.overrides ?? const [], + ), + ]; + } + + @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'), + ), + 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), + 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 _addProgram() async { + final program = await Navigator.of(context).push( + 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 _openProgramDetail(WorkoutTemplateProgramDraft program) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + WorkoutTemplateProgramDetailScreen(program: program), + ), + ); + if (!mounted) return; + setState(() {}); + } + + Future _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); + } + } + } + + 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>( + future: programUseCases.listActive(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final programs = snapshot.data ?? const []; + 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 createState() => + _WorkoutTemplateProgramDetailScreenState(); +} + +final class _WorkoutTemplateProgramDetailScreenState + extends State { + @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', + border: OutlineInputBorder(), + ), + 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 overrides, + ) { + final snapshot = + jsonDecode(program.programSnapshotJson) as Map; + 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 exercises; + + WorkoutTemplateProgramConfig toConfig() { + return WorkoutTemplateProgramConfig( + clientKey: clientKey, + existingMetadata: existingMetadata, + sourceProgramId: sourceProgramId, + programNameSnapshot: programNameSnapshot, + defaultRestSecondsSnapshot: defaultRestSecondsSnapshot, + programSnapshotJson: programSnapshotJson, + ); + } + + Iterable 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 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)), + ), + ); + } +} + +List _parseExercises( + Map snapshot, +) { + final rawExercises = snapshot['exercises'] as List? ?? const []; + return rawExercises.map((raw) { + final exercise = raw as Map; + 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( + 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( + 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; + return (snapshot['exercises'] as List? ?? 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!)), + ], + ], + ), + ), + ); + } +} diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 73e820e..07bbd58 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -95,6 +95,15 @@ final class _FakeWorkoutTemplateRepository @override Future findById(String id) async => null; + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + @override Future save(WorkoutTemplate template) async {} diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart new file mode 100644 index 0000000..6e3dfd1 --- /dev/null +++ b/test/presentation/workout_template_screen_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gametime/presentation/presentation.dart'; + +void main() { + testWidgets( + 'la vue programme de séance ne permet pas de changer les mesures actives', + (tester) async { + final program = WorkoutTemplateProgramDraft( + clientKey: 'program-1', + programNameSnapshot: 'Jambes', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: '{}', + exercises: [ + WorkoutTemplateExerciseDraft( + snapshotExerciseId: 'exercise-1', + exerciseNameSnapshot: 'Squat', + setsCount: 3, + timeEnabled: true, + repsEnabled: true, + scoreEnabled: false, + targetTimeSeconds: 45, + targetReps: 10, + ), + ], + ); + + await tester.pumpWidget( + MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)), + ); + + expect(find.byType(CheckboxListTile), findsNothing); + expect(find.text('Mesures à suivre'), findsNothing); + + await tester.enterText(find.byType(TextFormField).first, '5'); + await tester.pump(); + + expect(program.exercises.single.setsCountOverride, 5); + expect(find.text('5 séries · Temps + Répétitions'), findsOneWidget); + }, + ); +}