diff --git a/.ideai/tickets/7/carnet.md b/.ideai/tickets/7/carnet.md index 203b3a4..da76cbd 100644 --- a/.ideai/tickets/7/carnet.md +++ b/.ideai/tickets/7/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#7" -version: 4 +version: 6 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784301385567 +updatedAt: 1784307099507 --- Référence : mémoire "gametime-ux-conception" section 2. Repos affiché comme "Repos après cette série", attaché à la série/l'exercice précédent pour suivre le réordonnancement (poignée de déplacement). Pas de repos après la toute dernière série du programme. Ligne résumé attendue sur chaque carte exercice : "X séries · [mesures actives] · Repos Ys". \ No newline at end of file diff --git a/.ideai/tickets/7/issue.md b/.ideai/tickets/7/issue.md index 8e357ad..92f1360 100644 --- a/.ideai/tickets/7/issue.md +++ b/.ideai/tickets/7/issue.md @@ -2,7 +2,7 @@ id: "f955dede-b254-49da-95bb-f63adca76d05" number: 7 title: "[DevFrontend] Écran Création/édition de programme" -status: "open" +status: "closed" priority: "high" sprint: null links: [{"target":"#4","kind":"dependsOn"},{"target":"#6","kind":"dependsOn"}] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784301300945 -updatedAt: 1784301385567 -version: 4 +updatedAt: 1784307099507 +version: 6 --- Ajout d'exercices depuis la bibliothèque (recherche, badges de mesures). Cartes réordonnables par exercice. Configuration par exercice : nombre de séries, mesures à suivre (sous-ensemble de celles autorisées par l'exercice), cible par mesure activée, repos après chaque série (hérite du défaut programme, surchargeable). Gestion du cas "exercice archivé/supprimé de la bibliothèque". Cf. mémoire "gametime-ux-conception" section 2. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 3d009dc..9bfee44 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -77,13 +77,13 @@ "issueRef": "#7", "path": "7", "title": "[DevFrontend] Écran Création/édition de programme", - "status": "open", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784301385567 + "updatedAt": 1784307099507 }, { "issueRef": "#8", diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index e3cf965..c4b28ea 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -6,17 +6,20 @@ final class AppBootstrap { required this.database, required this.exerciseUseCases, required this.mediaUseCases, + required this.programUseCases, }); final AppDatabase database; final ExerciseUseCases exerciseUseCases; final MediaUseCases mediaUseCases; + final ProgramUseCases programUseCases; static Future create() async { final database = AppDatabase.open(); await database.customSelect('SELECT 1').get(); final exerciseRepository = DriftExerciseRepository(database); final mediaRepository = DriftMediaAssetRepository(database); + final programRepository = DriftProgramRepository(database); final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; @@ -37,6 +40,13 @@ final class AppBootstrap { ids: ids, originDeviceId: originDeviceId, ), + programUseCases: ProgramUseCases( + programRepository: programRepository, + exerciseRepository: exerciseRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ), ); } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 96492b6..0930fda 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -59,8 +59,10 @@ abstract interface class MediaAssetRepository { abstract interface class ProgramRepository { Future findById(String id); + Future> listActive(); Future save(Program program); Future saveExercise(ProgramExercise exercise); + Future replaceExercises(Program program, DateTime deletedAt); } abstract interface class WorkoutTemplateRepository { diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 78b054b..3f34af2 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -227,6 +227,10 @@ final class ProgramUseCases { final IdGenerator ids; final String originDeviceId; + Future findById(String id) => programRepository.findById(id); + + Future> listActive() => programRepository.listActive(); + Future create({ required String name, required int defaultRestSeconds, @@ -241,6 +245,69 @@ final class ProgramUseCases { return program; } + Future saveConfigured({ + String? id, + required String name, + required int defaultRestSeconds, + required List exercises, + }) async { + final now = clock.now(); + final existing = id == null ? null : await programRepository.findById(id); + if (id != null && existing == null) { + throw const DomainException('Program not found.'); + } + final programId = id ?? ids.newId(); + final metadata = + existing?.metadata.touch(now) ?? + EntityMetadata( + id: programId, + createdAt: now, + updatedAt: now, + originDeviceId: originDeviceId, + ); + final programExercises = []; + for (var index = 0; index < exercises.length; index++) { + final input = exercises[index]; + final exerciseMetadata = + input.existingMetadata?.touch(now) ?? + _newMetadata(ids, originDeviceId, now); + programExercises.add( + ProgramExercise( + metadata: exerciseMetadata, + programId: programId, + sourceExerciseId: input.sourceExerciseId, + position: index, + exerciseNameSnapshot: input.exerciseNameSnapshot, + exerciseDescriptionSnapshot: input.exerciseDescriptionSnapshot, + exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot, + exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot, + exerciseArchivedSnapshot: input.exerciseArchivedSnapshot, + availableTimeSnapshot: input.availableTimeSnapshot, + availableRepsSnapshot: input.availableRepsSnapshot, + availableScoreSnapshot: input.availableScoreSnapshot, + scoreLabelSnapshot: input.scoreLabelSnapshot, + scoreUnitSnapshot: input.scoreUnitSnapshot, + setsCount: input.setsCount, + timeEnabled: input.enabledMeasures.contains(WorkoutMeasure.time), + repsEnabled: input.enabledMeasures.contains(WorkoutMeasure.reps), + scoreEnabled: input.enabledMeasures.contains(WorkoutMeasure.score), + targetTimeSeconds: input.targetTimeSeconds, + targetReps: input.targetReps, + targetScore: input.targetScore, + restSecondsOverride: input.restSecondsOverride, + ), + ); + } + final program = Program( + metadata: metadata, + name: name, + defaultRestSeconds: defaultRestSeconds, + exercises: programExercises, + ); + await programRepository.replaceExercises(program, now); + return program; + } + Future addExercise({ required String programId, required String exerciseId, @@ -274,6 +341,48 @@ final class ProgramUseCases { } } +final class ProgramExerciseConfig { + const ProgramExerciseConfig({ + this.existingMetadata, + this.sourceExerciseId, + required this.exerciseNameSnapshot, + this.exerciseDescriptionSnapshot, + this.exerciseImageMediaIdSnapshot, + this.exerciseVideoMediaIdSnapshot, + this.exerciseArchivedSnapshot = false, + required this.availableTimeSnapshot, + required this.availableRepsSnapshot, + required this.availableScoreSnapshot, + this.scoreLabelSnapshot, + this.scoreUnitSnapshot, + required this.setsCount, + required this.enabledMeasures, + this.targetTimeSeconds, + this.targetReps, + this.targetScore, + this.restSecondsOverride, + }); + + final EntityMetadata? existingMetadata; + final String? sourceExerciseId; + final String exerciseNameSnapshot; + final String? exerciseDescriptionSnapshot; + final String? exerciseImageMediaIdSnapshot; + final String? exerciseVideoMediaIdSnapshot; + final bool exerciseArchivedSnapshot; + final bool availableTimeSnapshot; + final bool availableRepsSnapshot; + final bool availableScoreSnapshot; + final String? scoreLabelSnapshot; + final String? scoreUnitSnapshot; + final int setsCount; + final Set enabledMeasures; + final int? targetTimeSeconds; + final int? targetReps; + final double? targetScore; + final int? restSecondsOverride; +} + final class WorkoutTemplateUseCases { const WorkoutTemplateUseCases({ required this.templateRepository, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 70f48f1..85e8d10 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -221,11 +221,13 @@ final class Program { Program copyWith({ List? exercises, EntityMetadata? metadata, + String? name, + int? defaultRestSeconds, }) { return Program( metadata: metadata ?? this.metadata, - name: name, - defaultRestSeconds: defaultRestSeconds, + name: name ?? this.name, + defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds, exercises: exercises ?? this.exercises, ); } diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 3d14139..559f455 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -87,15 +87,18 @@ final class DriftProgramRepository implements ProgramRepository { @override Future findById(String id) async { - final row = await (database.select( - database.programs, - )..where((table) => table.id.equals(id))).getSingleOrNull(); + final query = database.select(database.programs) + ..where((table) => table.id.equals(id) & table.deletedAt.isNull()); + final row = await query.getSingleOrNull(); if (row == null) { return null; } final exercises = await (database.select(database.programExercises) - ..where((table) => table.programId.equals(id)) + ..where( + (table) => + table.programId.equals(id) & table.deletedAt.isNull(), + ) ..orderBy([(table) => OrderingTerm.asc(table.position)])) .get(); return _programFromRow( @@ -104,6 +107,30 @@ final class DriftProgramRepository implements ProgramRepository { ); } + @override + Future> listActive() async { + final rows = + await (database.select(database.programs) + ..where((table) => table.deletedAt.isNull()) + ..orderBy([(table) => OrderingTerm.asc(table.name)])) + .get(); + final programs = []; + for (final row in rows) { + final exercises = + await (database.select(database.programExercises) + ..where( + (table) => + table.programId.equals(row.id) & table.deletedAt.isNull(), + ) + ..orderBy([(table) => OrderingTerm.asc(table.position)])) + .get(); + programs.add( + _programFromRow(row, exercises.map(_programExerciseFromRow).toList()), + ); + } + return programs; + } + @override Future save(domain.Program program) async { await database @@ -120,6 +147,40 @@ final class DriftProgramRepository implements ProgramRepository { .into(database.programExercises) .insertOnConflictUpdate(_programExerciseCompanion(exercise)); } + + @override + Future replaceExercises( + domain.Program program, + DateTime deletedAt, + ) async { + await database.transaction(() async { + await save(program); + final activeIds = program.exercises + .map((exercise) => exercise.metadata.id) + .toSet(); + final update = database.update(database.programExercises); + if (activeIds.isEmpty) { + update.where( + (table) => + table.programId.equals(program.metadata.id) & + table.deletedAt.isNull(), + ); + } else { + update.where( + (table) => + table.programId.equals(program.metadata.id) & + table.deletedAt.isNull() & + table.id.isNotIn(activeIds), + ); + } + await update.write( + db.ProgramExercisesCompanion( + deletedAt: Value(deletedAt), + updatedAt: Value(deletedAt), + ), + ); + }); + } } final class DriftWorkoutTemplateRepository diff --git a/lib/presentation/game_time_app.dart b/lib/presentation/game_time_app.dart index b51c371..379a2b4 100644 --- a/lib/presentation/game_time_app.dart +++ b/lib/presentation/game_time_app.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../application/app_bootstrap.dart'; -import 'exercise_library_screen.dart'; +import 'home_screen.dart'; final class GameTimeApp extends StatelessWidget { const GameTimeApp({required this.bootstrap, super.key}); @@ -16,10 +16,7 @@ final class GameTimeApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E7C66)), useMaterial3: true, ), - home: ExerciseLibraryScreen( - exerciseUseCases: bootstrap.exerciseUseCases, - mediaUseCases: bootstrap.mediaUseCases, - ), + home: HomeScreen(bootstrap: bootstrap), ); } } diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart new file mode 100644 index 0000000..5091ec0 --- /dev/null +++ b/lib/presentation/home_screen.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../application/app_bootstrap.dart'; +import 'exercise_library_screen.dart'; +import 'program_screen.dart'; + +final class HomeScreen extends StatelessWidget { + const HomeScreen({required this.bootstrap, super.key}); + + final AppBootstrap bootstrap; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('GameTime')), + body: ListView( + children: [ + ListTile( + leading: const Icon(Icons.fitness_center), + title: const Text('Exercices'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ExerciseLibraryScreen( + exerciseUseCases: bootstrap.exerciseUseCases, + mediaUseCases: bootstrap.mediaUseCases, + ), + ), + ), + ), + ListTile( + leading: const Icon(Icons.list_alt), + title: const Text('Programmes'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ProgramListScreen( + programUseCases: bootstrap.programUseCases, + exerciseUseCases: bootstrap.exerciseUseCases, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index c3884d0..df8a784 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -1,2 +1,4 @@ export 'game_time_app.dart'; export 'exercise_library_screen.dart'; +export 'home_screen.dart'; +export 'program_screen.dart'; diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart new file mode 100644 index 0000000..a3b16c7 --- /dev/null +++ b/lib/presentation/program_screen.dart @@ -0,0 +1,772 @@ +import 'package:flutter/material.dart'; + +import '../application/application.dart'; +import '../domain/domain.dart'; +import 'exercise_library_screen.dart'; + +final class ProgramListScreen extends StatefulWidget { + const ProgramListScreen({ + required this.programUseCases, + required this.exerciseUseCases, + super.key, + }); + + final ProgramUseCases programUseCases; + final ExerciseUseCases exerciseUseCases; + + @override + State createState() => _ProgramListScreenState(); +} + +final class _ProgramListScreenState extends State { + late Future> _programs; + + @override + void initState() { + super.initState(); + _programs = widget.programUseCases.listActive(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Programmes')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openForm(), + icon: const Icon(Icons.add), + label: const Text('Créer'), + ), + body: FutureBuilder>( + future: _programs, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _CenteredMessage( + title: 'Impossible de charger les programmes', + actionLabel: 'Réessayer', + onAction: _reload, + ); + } + final programs = snapshot.data ?? const []; + if (programs.isEmpty) { + return _CenteredMessage( + title: 'Aucun programme pour le moment', + message: + 'Crée un programme pour organiser tes exercices en séries.', + actionLabel: 'Créer un programme', + onAction: () => _openForm(), + ); + } + return ListView.separated( + itemCount: programs.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final program = programs[index]; + return ListTile( + title: Text(program.name), + subtitle: Text(_programSummary(program)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openForm(program: program), + ); + }, + ); + }, + ), + ); + } + + void _reload() { + setState(() { + _programs = widget.programUseCases.listActive(); + }); + } + + Future _openForm({Program? program}) async { + final changed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ProgramFormScreen( + programUseCases: widget.programUseCases, + exerciseUseCases: widget.exerciseUseCases, + program: program, + ), + ), + ); + if (changed == true) { + if (!mounted) return; + _reload(); + } + } +} + +final class ProgramFormScreen extends StatefulWidget { + const ProgramFormScreen({ + required this.programUseCases, + required this.exerciseUseCases, + this.program, + super.key, + }); + + final ProgramUseCases programUseCases; + final ExerciseUseCases exerciseUseCases; + final Program? program; + + @override + State createState() => _ProgramFormScreenState(); +} + +final class _ProgramFormScreenState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _defaultRestController; + late final List<_ProgramExerciseDraft> _exercises; + var _saving = false; + + bool get _isEditing => widget.program != null; + + int get _defaultRestSeconds { + return int.tryParse(_defaultRestController.text.trim()) ?? 0; + } + + @override + void initState() { + super.initState(); + final program = widget.program; + _nameController = TextEditingController(text: program?.name); + _defaultRestController = TextEditingController( + text: '${program?.defaultRestSeconds ?? 60}', + ); + _exercises = [ + for (final exercise in program?.exercises ?? const []) + _ProgramExerciseDraft.fromProgramExercise(exercise), + ]; + } + + @override + void dispose() { + _nameController.dispose(); + _defaultRestController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + _isEditing ? 'Modifier le programme' : 'Créer un programme', + ), + ), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + label: const Text('Enregistrer'), + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Nom', + border: OutlineInputBorder(), + ), + validator: (value) => value == null || value.trim().isEmpty + ? 'Le nom est obligatoire.' + : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _defaultRestController, + decoration: const InputDecoration( + labelText: 'Repos par défaut (s)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: _nonNegativeIntValidator, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _addExercise, + icon: const Icon(Icons.add), + label: const Text('Ajouter un exercice'), + ), + ), + ], + ), + ), + ), + Expanded( + child: _exercises.isEmpty + ? const _CenteredMessage( + title: 'Aucun exercice ajouté', + message: 'Ajoute un exercice depuis la bibliothèque.', + ) + : ReorderableListView.builder( + buildDefaultDragHandles: false, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + itemCount: _exercises.length, + onReorder: _reorderExercise, + itemBuilder: (context, index) { + final exercise = _exercises[index]; + return _ProgramExerciseCard( + key: ValueKey(exercise.key), + draft: exercise, + defaultRestSeconds: _defaultRestSeconds, + onChanged: () => setState(() {}), + index: index, + ); + }, + ), + ), + ], + ), + ); + } + + Future _addExercise() async { + final exercise = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + ExerciseSelectionScreen(exerciseUseCases: widget.exerciseUseCases), + ), + ); + if (exercise == null) return; + if (!mounted) return; + setState(() { + _exercises.add(_ProgramExerciseDraft.fromExercise(exercise)); + }); + } + + void _reorderExercise(int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _exercises.removeAt(oldIndex); + _exercises.insert(newIndex, item); + }); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) { + return; + } + setState(() => _saving = true); + try { + await widget.programUseCases.saveConfigured( + id: widget.program?.metadata.id, + name: _nameController.text.trim(), + defaultRestSeconds: _defaultRestSeconds, + exercises: _exercises.map((exercise) => exercise.toConfig()).toList(), + ); + if (mounted) { + Navigator.of(context).pop(true); + } + } on Exception catch (error) { + _showSnackBar(error.toString()); + } finally { + if (mounted) { + setState(() => _saving = false); + } + } + } + + String? _nonNegativeIntValidator(String? value) { + final number = int.tryParse(value?.trim() ?? ''); + if (number == null || number < 0) { + return 'Saisis un nombre positif.'; + } + return null; + } + + void _showSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +final class ExerciseSelectionScreen extends StatefulWidget { + const ExerciseSelectionScreen({required this.exerciseUseCases, super.key}); + + final ExerciseUseCases exerciseUseCases; + + @override + State createState() => + _ExerciseSelectionScreenState(); +} + +final class _ExerciseSelectionScreenState + extends State { + final _searchController = TextEditingController(); + var _selectedMeasures = {}; + late Future> _exercises; + + @override + void initState() { + super.initState(); + _exercises = widget.exerciseUseCases.listActive(); + _searchController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Ajouter un exercice')), + body: FutureBuilder>( + future: _exercises, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final exercises = _filter(snapshot.data ?? const []); + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _searchController, + decoration: const InputDecoration( + labelText: 'Rechercher', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: WorkoutMeasure.values.map((measure) { + return FilterChip( + label: Text(_measureLabel(measure)), + selected: _selectedMeasures.contains(measure), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedMeasures = { + ..._selectedMeasures, + measure, + }; + } else { + _selectedMeasures = {..._selectedMeasures} + ..remove(measure); + } + }); + }, + ); + }).toList(), + ), + ], + ), + ), + if (exercises.isEmpty) + const _CenteredMessage( + title: 'Aucun exercice disponible', + message: 'Crée un exercice dans la bibliothèque.', + ) + else + for (final exercise in exercises) + ExerciseListTile( + exercise: exercise, + onTap: () => Navigator.of(context).pop(exercise), + ), + ], + ); + }, + ), + ); + } + + List _filter(List exercises) { + final query = _searchController.text.trim().toLowerCase(); + return exercises.where((exercise) { + final matchesQuery = + query.isEmpty || + exercise.name.toLowerCase().contains(query) || + (exercise.description?.toLowerCase().contains(query) ?? false); + final matchesMeasures = + _selectedMeasures.isEmpty || + _selectedMeasures.every(exercise.availableMeasures.contains); + return matchesQuery && matchesMeasures; + }).toList(); + } +} + +final class _ProgramExerciseCard extends StatelessWidget { + const _ProgramExerciseCard({ + required this.draft, + required this.defaultRestSeconds, + required this.onChanged, + required this.index, + super.key, + }); + + final _ProgramExerciseDraft draft; + final int defaultRestSeconds; + final VoidCallback onChanged; + final int index; + + @override + Widget build(BuildContext context) { + final scoreUnit = draft.scoreUnitSnapshot; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon(Icons.drag_handle), + ), + ), + Expanded( + child: Text( + draft.exerciseNameSnapshot, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (draft.exerciseArchivedSnapshot) + const Chip(label: Text('Exercice archivé')), + ], + ), + const SizedBox(height: 4), + Text(_exerciseSummary(draft, defaultRestSeconds)), + const SizedBox(height: 12), + TextFormField( + initialValue: '${draft.setsCount}', + decoration: const InputDecoration( + labelText: 'Nombre de séries', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) { + draft.setsCount = int.tryParse(value) ?? draft.setsCount; + onChanged(); + }, + ), + const SizedBox(height: 12), + Text( + 'Mesures à suivre', + style: Theme.of(context).textTheme.titleSmall, + ), + if (draft.availableTimeSnapshot) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Temps'), + value: draft.enabledMeasures.contains(WorkoutMeasure.time), + onChanged: (value) => _toggle(WorkoutMeasure.time, value), + ), + if (draft.availableRepsSnapshot) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Répétitions'), + value: draft.enabledMeasures.contains(WorkoutMeasure.reps), + onChanged: (value) => _toggle(WorkoutMeasure.reps, value), + ), + if (draft.availableScoreSnapshot) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'), + value: draft.enabledMeasures.contains(WorkoutMeasure.score), + onChanged: (value) => _toggle(WorkoutMeasure.score, value), + ), + if (draft.enabledMeasures.contains(WorkoutMeasure.time)) + _NumberField( + label: 'Cible temps (s)', + initialValue: draft.targetTimeSeconds, + onChanged: (value) => draft.targetTimeSeconds = value?.round(), + ), + if (draft.enabledMeasures.contains(WorkoutMeasure.reps)) + _NumberField( + label: 'Cible répétitions', + initialValue: draft.targetReps, + onChanged: (value) => draft.targetReps = value?.round(), + ), + if (draft.enabledMeasures.contains(WorkoutMeasure.score)) + _NumberField( + label: scoreUnit == null + ? 'Cible score' + : 'Cible score ($scoreUnit)', + initialValue: draft.targetScore, + onChanged: (value) => draft.targetScore = value, + ), + const SizedBox(height: 12), + TextFormField( + initialValue: + '${draft.restSecondsOverride ?? defaultRestSeconds}', + decoration: const InputDecoration( + labelText: 'Repos après chaque série (s)', + helperText: 'Pas de repos après la toute dernière série.', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) { + draft.restSecondsOverride = int.tryParse(value); + onChanged(); + }, + ), + ], + ), + ), + ); + } + + void _toggle(WorkoutMeasure measure, bool? value) { + if (value == true) { + draft.enabledMeasures = {...draft.enabledMeasures, measure}; + } else if (draft.enabledMeasures.length > 1) { + draft.enabledMeasures = {...draft.enabledMeasures}..remove(measure); + } + onChanged(); + } +} + +final class _NumberField extends StatelessWidget { + const _NumberField({ + required this.label, + required this.initialValue, + required this.onChanged, + }); + + final String label; + final num? initialValue; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: TextFormField( + initialValue: initialValue?.toString(), + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) => onChanged(double.tryParse(value)), + ), + ); + } +} + +final class _ProgramExerciseDraft { + _ProgramExerciseDraft({ + required this.key, + this.existingMetadata, + this.sourceExerciseId, + required this.exerciseNameSnapshot, + this.exerciseDescriptionSnapshot, + this.exerciseImageMediaIdSnapshot, + this.exerciseVideoMediaIdSnapshot, + this.exerciseArchivedSnapshot = false, + required this.availableTimeSnapshot, + required this.availableRepsSnapshot, + required this.availableScoreSnapshot, + this.scoreLabelSnapshot, + this.scoreUnitSnapshot, + required this.setsCount, + required this.enabledMeasures, + this.targetTimeSeconds, + this.targetReps, + this.targetScore, + this.restSecondsOverride, + }); + + factory _ProgramExerciseDraft.fromExercise(Exercise exercise) { + return _ProgramExerciseDraft( + key: UniqueKey().toString(), + sourceExerciseId: exercise.metadata.id, + exerciseNameSnapshot: exercise.name, + exerciseDescriptionSnapshot: exercise.description, + exerciseImageMediaIdSnapshot: exercise.imageMediaId, + exerciseVideoMediaIdSnapshot: exercise.videoMediaId, + exerciseArchivedSnapshot: exercise.archivedAt != null, + availableTimeSnapshot: exercise.hasTimeMeasure, + availableRepsSnapshot: exercise.hasRepsMeasure, + availableScoreSnapshot: exercise.hasScoreMeasure, + scoreLabelSnapshot: exercise.scoreLabel, + scoreUnitSnapshot: exercise.scoreUnit, + setsCount: 3, + enabledMeasures: exercise.availableMeasures, + ); + } + + factory _ProgramExerciseDraft.fromProgramExercise(ProgramExercise exercise) { + return _ProgramExerciseDraft( + key: exercise.metadata.id, + existingMetadata: exercise.metadata, + sourceExerciseId: exercise.sourceExerciseId, + exerciseNameSnapshot: exercise.exerciseNameSnapshot, + exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot, + exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot, + exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot, + exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot, + availableTimeSnapshot: exercise.availableTimeSnapshot, + availableRepsSnapshot: exercise.availableRepsSnapshot, + availableScoreSnapshot: exercise.availableScoreSnapshot, + scoreLabelSnapshot: exercise.scoreLabelSnapshot, + scoreUnitSnapshot: exercise.scoreUnitSnapshot, + setsCount: exercise.setsCount, + enabledMeasures: { + if (exercise.timeEnabled) WorkoutMeasure.time, + if (exercise.repsEnabled) WorkoutMeasure.reps, + if (exercise.scoreEnabled) WorkoutMeasure.score, + }, + targetTimeSeconds: exercise.targetTimeSeconds, + targetReps: exercise.targetReps, + targetScore: exercise.targetScore, + restSecondsOverride: exercise.restSecondsOverride, + ); + } + + final String key; + final EntityMetadata? existingMetadata; + final String? sourceExerciseId; + final String exerciseNameSnapshot; + final String? exerciseDescriptionSnapshot; + final String? exerciseImageMediaIdSnapshot; + final String? exerciseVideoMediaIdSnapshot; + final bool exerciseArchivedSnapshot; + final bool availableTimeSnapshot; + final bool availableRepsSnapshot; + final bool availableScoreSnapshot; + final String? scoreLabelSnapshot; + final String? scoreUnitSnapshot; + int setsCount; + Set enabledMeasures; + int? targetTimeSeconds; + int? targetReps; + double? targetScore; + int? restSecondsOverride; + + ProgramExerciseConfig toConfig() { + return ProgramExerciseConfig( + existingMetadata: existingMetadata, + sourceExerciseId: sourceExerciseId, + exerciseNameSnapshot: exerciseNameSnapshot, + exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, + exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, + exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, + exerciseArchivedSnapshot: exerciseArchivedSnapshot, + availableTimeSnapshot: availableTimeSnapshot, + availableRepsSnapshot: availableRepsSnapshot, + availableScoreSnapshot: availableScoreSnapshot, + scoreLabelSnapshot: scoreLabelSnapshot, + scoreUnitSnapshot: scoreUnitSnapshot, + setsCount: setsCount, + enabledMeasures: enabledMeasures, + targetTimeSeconds: targetTimeSeconds, + targetReps: targetReps, + targetScore: targetScore, + restSecondsOverride: restSecondsOverride, + ); + } +} + +String _programSummary(Program program) { + final exerciseCount = program.exercises.length; + final setsCount = program.exercises.fold( + 0, + (total, exercise) => total + exercise.setsCount, + ); + return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''} · ' + '$setsCount série${setsCount > 1 ? 's' : ''}'; +} + +String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) { + final measures = draft.enabledMeasures + .map((measure) { + final unit = draft.scoreUnitSnapshot?.trim(); + if (measure == WorkoutMeasure.score && + unit != null && + unit.isNotEmpty) { + return 'Score ($unit)'; + } + return _measureLabel(measure); + }) + .join(' + '); + final rest = draft.restSecondsOverride ?? defaultRestSeconds; + return '${draft.setsCount} séries · $measures · Repos $rest s'; +} + +String _measureLabel(WorkoutMeasure measure) { + return switch (measure) { + WorkoutMeasure.time => 'Temps', + WorkoutMeasure.reps => 'Répétitions', + WorkoutMeasure.score => 'Score', + }; +} + +final class _CenteredMessage extends StatelessWidget { + const _CenteredMessage({ + required this.title, + this.message, + this.actionLabel, + this.onAction, + }); + + final String title; + final String? message; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text(message!, textAlign: TextAlign.center), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 16), + FilledButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ), + ); + } +} diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 603764b..73e820e 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -109,6 +109,12 @@ final class _FakeProgramRepository implements ProgramRepository { @override Future findById(String id) async => null; + @override + Future> listActive() async => const []; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + @override Future save(Program program) async {} diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart new file mode 100644 index 0000000..71afa4e --- /dev/null +++ b/test/presentation/program_screen_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:gametime/presentation/presentation.dart'; + +void main() { + testWidgets( + 'ajoute un exercice au programme avec sa configuration par défaut', + (tester) async { + final exerciseRepository = _FakeExerciseRepository() + ..exercises.add( + Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: true, + hasScoreMeasure: false, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: ProgramFormScreen( + programUseCases: _programUseCases( + _FakeProgramRepository(), + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + + await tester.tap(find.text('Ajouter un exercice')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Squat')); + await tester.pumpAndSettle(); + + expect(find.text('Squat'), findsOneWidget); + expect( + find.text('3 séries · Temps + Répétitions · Repos 60 s'), + findsOneWidget, + ); + expect(find.widgetWithText(CheckboxListTile, 'Temps'), findsOneWidget); + expect( + find.widgetWithText(CheckboxListTile, 'Répétitions'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'limite les mesures aux mesures autorisées par l’exercice source', + (tester) async { + final exerciseRepository = _FakeExerciseRepository() + ..exercises.add( + Exercise( + metadata: _metadata('exercise-1'), + name: 'Pompes', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: ProgramFormScreen( + programUseCases: _programUseCases( + _FakeProgramRepository(), + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + + await tester.tap(find.text('Ajouter un exercice')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pompes')); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(CheckboxListTile, 'Répétitions'), + findsOneWidget, + ); + expect(find.widgetWithText(CheckboxListTile, 'Temps'), findsNothing); + expect(find.widgetWithText(CheckboxListTile, 'Score'), findsNothing); + }, + ); +} + +ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { + return ExerciseUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +ProgramUseCases _programUseCases( + _FakeProgramRepository programRepository, + _FakeExerciseRepository exerciseRepository, +) { + return ProgramUseCases( + programRepository: programRepository, + exerciseRepository: exerciseRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +EntityMetadata _metadata(String id) { + return EntityMetadata( + id: id, + createdAt: DateTime.utc(2026, 7, 17), + updatedAt: DateTime.utc(2026, 7, 17), + originDeviceId: 'device-1', + ); +} + +final class _FakeClock implements Clock { + const _FakeClock(this.value); + + final DateTime value; + + @override + DateTime now() => value; +} + +final class _FakeIds implements IdGenerator { + var _next = 0; + + @override + String newId() { + _next += 1; + return 'id-$_next'; + } +} + +final class _FakeExerciseRepository implements ExerciseRepository { + final exercises = []; + final saved = []; + + @override + Future findById(String id) async { + for (final exercise in exercises) { + if (exercise.metadata.id == id) { + return exercise; + } + } + return null; + } + + @override + Future isReferencedByProgram(String id) async => false; + + @override + Future> listActive() async => exercises; + + @override + Future save(Exercise exercise) async { + saved.add(exercise); + } +} + +final class _FakeProgramRepository implements ProgramRepository { + final programs = []; + + @override + Future findById(String id) async { + for (final program in programs) { + if (program.metadata.id == id) { + return program; + } + } + return null; + } + + @override + Future> listActive() async => programs; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async { + programs.removeWhere((item) => item.metadata.id == program.metadata.id); + programs.add(program); + } + + @override + Future save(Program program) async { + programs.add(program); + } + + @override + Future saveExercise(ProgramExercise exercise) async {} +}