feat(ui): écran Création/édition de programme

Ajoute l'écran de programme (presentation/program_screen.dart) et un
écran d'accueil (presentation/home_screen.dart) pour la navigation,
avec les ajustements application/domaine/repositories nécessaires.
flutter analyze propre, 11/11 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:51:49 +02:00
parent 00ae95f5c0
commit 1ae6248fb4
11 changed files with 1218 additions and 11 deletions

View File

@ -6,17 +6,20 @@ final class AppBootstrap {
required this.database, required this.database,
required this.exerciseUseCases, required this.exerciseUseCases,
required this.mediaUseCases, required this.mediaUseCases,
required this.programUseCases,
}); });
final AppDatabase database; final AppDatabase database;
final ExerciseUseCases exerciseUseCases; final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases; final MediaUseCases mediaUseCases;
final ProgramUseCases programUseCases;
static Future<AppBootstrap> create() async { static Future<AppBootstrap> create() async {
final database = AppDatabase.open(); final database = AppDatabase.open();
await database.customSelect('SELECT 1').get(); await database.customSelect('SELECT 1').get();
final exerciseRepository = DriftExerciseRepository(database); final exerciseRepository = DriftExerciseRepository(database);
final mediaRepository = DriftMediaAssetRepository(database); final mediaRepository = DriftMediaAssetRepository(database);
final programRepository = DriftProgramRepository(database);
final ids = LocalIdGenerator(); final ids = LocalIdGenerator();
const clock = SystemClock(); const clock = SystemClock();
const originDeviceId = 'local-device'; const originDeviceId = 'local-device';
@ -37,6 +40,13 @@ final class AppBootstrap {
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
), ),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
); );
} }

View File

@ -59,8 +59,10 @@ abstract interface class MediaAssetRepository {
abstract interface class ProgramRepository { abstract interface class ProgramRepository {
Future<Program?> findById(String id); Future<Program?> findById(String id);
Future<List<Program>> listActive();
Future<void> save(Program program); Future<void> save(Program program);
Future<void> saveExercise(ProgramExercise exercise); Future<void> saveExercise(ProgramExercise exercise);
Future<void> replaceExercises(Program program, DateTime deletedAt);
} }
abstract interface class WorkoutTemplateRepository { abstract interface class WorkoutTemplateRepository {

View File

@ -227,6 +227,10 @@ final class ProgramUseCases {
final IdGenerator ids; final IdGenerator ids;
final String originDeviceId; final String originDeviceId;
Future<Program?> findById(String id) => programRepository.findById(id);
Future<List<Program>> listActive() => programRepository.listActive();
Future<Program> create({ Future<Program> create({
required String name, required String name,
required int defaultRestSeconds, required int defaultRestSeconds,
@ -241,6 +245,69 @@ final class ProgramUseCases {
return program; return program;
} }
Future<Program> saveConfigured({
String? id,
required String name,
required int defaultRestSeconds,
required List<ProgramExerciseConfig> 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 = <ProgramExercise>[];
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<ProgramExercise> addExercise({ Future<ProgramExercise> addExercise({
required String programId, required String programId,
required String exerciseId, 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<WorkoutMeasure> enabledMeasures;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? restSecondsOverride;
}
final class WorkoutTemplateUseCases { final class WorkoutTemplateUseCases {
const WorkoutTemplateUseCases({ const WorkoutTemplateUseCases({
required this.templateRepository, required this.templateRepository,

View File

@ -221,11 +221,13 @@ final class Program {
Program copyWith({ Program copyWith({
List<ProgramExercise>? exercises, List<ProgramExercise>? exercises,
EntityMetadata? metadata, EntityMetadata? metadata,
String? name,
int? defaultRestSeconds,
}) { }) {
return Program( return Program(
metadata: metadata ?? this.metadata, metadata: metadata ?? this.metadata,
name: name, name: name ?? this.name,
defaultRestSeconds: defaultRestSeconds, defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds,
exercises: exercises ?? this.exercises, exercises: exercises ?? this.exercises,
); );
} }

View File

@ -87,15 +87,18 @@ final class DriftProgramRepository implements ProgramRepository {
@override @override
Future<domain.Program?> findById(String id) async { Future<domain.Program?> findById(String id) async {
final row = await (database.select( final query = database.select(database.programs)
database.programs, ..where((table) => table.id.equals(id) & table.deletedAt.isNull());
)..where((table) => table.id.equals(id))).getSingleOrNull(); final row = await query.getSingleOrNull();
if (row == null) { if (row == null) {
return null; return null;
} }
final exercises = final exercises =
await (database.select(database.programExercises) 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)])) ..orderBy([(table) => OrderingTerm.asc(table.position)]))
.get(); .get();
return _programFromRow( return _programFromRow(
@ -104,6 +107,30 @@ final class DriftProgramRepository implements ProgramRepository {
); );
} }
@override
Future<List<domain.Program>> listActive() async {
final rows =
await (database.select(database.programs)
..where((table) => table.deletedAt.isNull())
..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get();
final programs = <domain.Program>[];
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 @override
Future<void> save(domain.Program program) async { Future<void> save(domain.Program program) async {
await database await database
@ -120,6 +147,40 @@ final class DriftProgramRepository implements ProgramRepository {
.into(database.programExercises) .into(database.programExercises)
.insertOnConflictUpdate(_programExerciseCompanion(exercise)); .insertOnConflictUpdate(_programExerciseCompanion(exercise));
} }
@override
Future<void> 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 final class DriftWorkoutTemplateRepository

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../application/app_bootstrap.dart'; import '../application/app_bootstrap.dart';
import 'exercise_library_screen.dart'; import 'home_screen.dart';
final class GameTimeApp extends StatelessWidget { final class GameTimeApp extends StatelessWidget {
const GameTimeApp({required this.bootstrap, super.key}); const GameTimeApp({required this.bootstrap, super.key});
@ -16,10 +16,7 @@ final class GameTimeApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E7C66)), colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E7C66)),
useMaterial3: true, useMaterial3: true,
), ),
home: ExerciseLibraryScreen( home: HomeScreen(bootstrap: bootstrap),
exerciseUseCases: bootstrap.exerciseUseCases,
mediaUseCases: bootstrap.mediaUseCases,
),
); );
} }
} }

View File

@ -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,
),
),
),
),
],
),
);
}
}

View File

@ -1,2 +1,4 @@
export 'game_time_app.dart'; export 'game_time_app.dart';
export 'exercise_library_screen.dart'; export 'exercise_library_screen.dart';
export 'home_screen.dart';
export 'program_screen.dart';

View File

@ -0,0 +1,772 @@
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'exercise_library_screen.dart';
final class ProgramListScreen extends StatefulWidget {
const ProgramListScreen({
required this.programUseCases,
required this.exerciseUseCases,
super.key,
});
final ProgramUseCases programUseCases;
final ExerciseUseCases exerciseUseCases;
@override
State<ProgramListScreen> createState() => _ProgramListScreenState();
}
final class _ProgramListScreenState extends State<ProgramListScreen> {
late Future<List<Program>> _programs;
@override
void initState() {
super.initState();
_programs = widget.programUseCases.listActive();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Programmes')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(),
icon: const Icon(Icons.add),
label: const Text('Créer'),
),
body: FutureBuilder<List<Program>>(
future: _programs,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return _CenteredMessage(
title: 'Impossible de charger les programmes',
actionLabel: 'Réessayer',
onAction: _reload,
);
}
final programs = snapshot.data ?? const <Program>[];
if (programs.isEmpty) {
return _CenteredMessage(
title: 'Aucun programme pour le moment',
message:
'Crée un programme pour organiser tes exercices en séries.',
actionLabel: 'Créer un programme',
onAction: () => _openForm(),
);
}
return ListView.separated(
itemCount: programs.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final program = programs[index];
return ListTile(
title: Text(program.name),
subtitle: Text(_programSummary(program)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openForm(program: program),
);
},
);
},
),
);
}
void _reload() {
setState(() {
_programs = widget.programUseCases.listActive();
});
}
Future<void> _openForm({Program? program}) async {
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => ProgramFormScreen(
programUseCases: widget.programUseCases,
exerciseUseCases: widget.exerciseUseCases,
program: program,
),
),
);
if (changed == true) {
if (!mounted) return;
_reload();
}
}
}
final class ProgramFormScreen extends StatefulWidget {
const ProgramFormScreen({
required this.programUseCases,
required this.exerciseUseCases,
this.program,
super.key,
});
final ProgramUseCases programUseCases;
final ExerciseUseCases exerciseUseCases;
final Program? program;
@override
State<ProgramFormScreen> createState() => _ProgramFormScreenState();
}
final class _ProgramFormScreenState extends State<ProgramFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _defaultRestController;
late final List<_ProgramExerciseDraft> _exercises;
var _saving = false;
bool get _isEditing => widget.program != null;
int get _defaultRestSeconds {
return int.tryParse(_defaultRestController.text.trim()) ?? 0;
}
@override
void initState() {
super.initState();
final program = widget.program;
_nameController = TextEditingController(text: program?.name);
_defaultRestController = TextEditingController(
text: '${program?.defaultRestSeconds ?? 60}',
);
_exercises = [
for (final exercise in program?.exercises ?? const <ProgramExercise>[])
_ProgramExerciseDraft.fromProgramExercise(exercise),
];
}
@override
void dispose() {
_nameController.dispose();
_defaultRestController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
_isEditing ? 'Modifier le programme' : 'Créer un programme',
),
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
: null,
),
const SizedBox(height: 12),
TextFormField(
controller: _defaultRestController,
decoration: const InputDecoration(
labelText: 'Repos par défaut (s)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: _nonNegativeIntValidator,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _addExercise,
icon: const Icon(Icons.add),
label: const Text('Ajouter un exercice'),
),
),
],
),
),
),
Expanded(
child: _exercises.isEmpty
? const _CenteredMessage(
title: 'Aucun exercice ajouté',
message: 'Ajoute un exercice depuis la bibliothèque.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _exercises.length,
onReorder: _reorderExercise,
itemBuilder: (context, index) {
final exercise = _exercises[index];
return _ProgramExerciseCard(
key: ValueKey(exercise.key),
draft: exercise,
defaultRestSeconds: _defaultRestSeconds,
onChanged: () => setState(() {}),
index: index,
);
},
),
),
],
),
);
}
Future<void> _addExercise() async {
final exercise = await Navigator.of(context).push<Exercise>(
MaterialPageRoute(
builder: (context) =>
ExerciseSelectionScreen(exerciseUseCases: widget.exerciseUseCases),
),
);
if (exercise == null) return;
if (!mounted) return;
setState(() {
_exercises.add(_ProgramExerciseDraft.fromExercise(exercise));
});
}
void _reorderExercise(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _exercises.removeAt(oldIndex);
_exercises.insert(newIndex, item);
});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _saving = true);
try {
await widget.programUseCases.saveConfigured(
id: widget.program?.metadata.id,
name: _nameController.text.trim(),
defaultRestSeconds: _defaultRestSeconds,
exercises: _exercises.map((exercise) => exercise.toConfig()).toList(),
);
if (mounted) {
Navigator.of(context).pop(true);
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
String? _nonNegativeIntValidator(String? value) {
final number = int.tryParse(value?.trim() ?? '');
if (number == null || number < 0) {
return 'Saisis un nombre positif.';
}
return null;
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
final class ExerciseSelectionScreen extends StatefulWidget {
const ExerciseSelectionScreen({required this.exerciseUseCases, super.key});
final ExerciseUseCases exerciseUseCases;
@override
State<ExerciseSelectionScreen> createState() =>
_ExerciseSelectionScreenState();
}
final class _ExerciseSelectionScreenState
extends State<ExerciseSelectionScreen> {
final _searchController = TextEditingController();
var _selectedMeasures = <WorkoutMeasure>{};
late Future<List<Exercise>> _exercises;
@override
void initState() {
super.initState();
_exercises = widget.exerciseUseCases.listActive();
_searchController.addListener(() => setState(() {}));
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ajouter un exercice')),
body: FutureBuilder<List<Exercise>>(
future: _exercises,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final exercises = _filter(snapshot.data ?? const <Exercise>[]);
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: 'Rechercher',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: WorkoutMeasure.values.map((measure) {
return FilterChip(
label: Text(_measureLabel(measure)),
selected: _selectedMeasures.contains(measure),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedMeasures = {
..._selectedMeasures,
measure,
};
} else {
_selectedMeasures = {..._selectedMeasures}
..remove(measure);
}
});
},
);
}).toList(),
),
],
),
),
if (exercises.isEmpty)
const _CenteredMessage(
title: 'Aucun exercice disponible',
message: 'Crée un exercice dans la bibliothèque.',
)
else
for (final exercise in exercises)
ExerciseListTile(
exercise: exercise,
onTap: () => Navigator.of(context).pop(exercise),
),
],
);
},
),
);
}
List<Exercise> _filter(List<Exercise> exercises) {
final query = _searchController.text.trim().toLowerCase();
return exercises.where((exercise) {
final matchesQuery =
query.isEmpty ||
exercise.name.toLowerCase().contains(query) ||
(exercise.description?.toLowerCase().contains(query) ?? false);
final matchesMeasures =
_selectedMeasures.isEmpty ||
_selectedMeasures.every(exercise.availableMeasures.contains);
return matchesQuery && matchesMeasures;
}).toList();
}
}
final class _ProgramExerciseCard extends StatelessWidget {
const _ProgramExerciseCard({
required this.draft,
required this.defaultRestSeconds,
required this.onChanged,
required this.index,
super.key,
});
final _ProgramExerciseDraft draft;
final int defaultRestSeconds;
final VoidCallback onChanged;
final int index;
@override
Widget build(BuildContext context) {
final scoreUnit = draft.scoreUnitSnapshot;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.drag_handle),
),
),
Expanded(
child: Text(
draft.exerciseNameSnapshot,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (draft.exerciseArchivedSnapshot)
const Chip(label: Text('Exercice archivé')),
],
),
const SizedBox(height: 4),
Text(_exerciseSummary(draft, defaultRestSeconds)),
const SizedBox(height: 12),
TextFormField(
initialValue: '${draft.setsCount}',
decoration: const InputDecoration(
labelText: 'Nombre de séries',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) {
draft.setsCount = int.tryParse(value) ?? draft.setsCount;
onChanged();
},
),
const SizedBox(height: 12),
Text(
'Mesures à suivre',
style: Theme.of(context).textTheme.titleSmall,
),
if (draft.availableTimeSnapshot)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: draft.enabledMeasures.contains(WorkoutMeasure.time),
onChanged: (value) => _toggle(WorkoutMeasure.time, value),
),
if (draft.availableRepsSnapshot)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: draft.enabledMeasures.contains(WorkoutMeasure.reps),
onChanged: (value) => _toggle(WorkoutMeasure.reps, value),
),
if (draft.availableScoreSnapshot)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'),
value: draft.enabledMeasures.contains(WorkoutMeasure.score),
onChanged: (value) => _toggle(WorkoutMeasure.score, value),
),
if (draft.enabledMeasures.contains(WorkoutMeasure.time))
_NumberField(
label: 'Cible temps (s)',
initialValue: draft.targetTimeSeconds,
onChanged: (value) => draft.targetTimeSeconds = value?.round(),
),
if (draft.enabledMeasures.contains(WorkoutMeasure.reps))
_NumberField(
label: 'Cible répétitions',
initialValue: draft.targetReps,
onChanged: (value) => draft.targetReps = value?.round(),
),
if (draft.enabledMeasures.contains(WorkoutMeasure.score))
_NumberField(
label: scoreUnit == null
? 'Cible score'
: 'Cible score ($scoreUnit)',
initialValue: draft.targetScore,
onChanged: (value) => draft.targetScore = value,
),
const SizedBox(height: 12),
TextFormField(
initialValue:
'${draft.restSecondsOverride ?? defaultRestSeconds}',
decoration: const InputDecoration(
labelText: 'Repos après chaque série (s)',
helperText: 'Pas de repos après la toute dernière série.',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) {
draft.restSecondsOverride = int.tryParse(value);
onChanged();
},
),
],
),
),
);
}
void _toggle(WorkoutMeasure measure, bool? value) {
if (value == true) {
draft.enabledMeasures = {...draft.enabledMeasures, measure};
} else if (draft.enabledMeasures.length > 1) {
draft.enabledMeasures = {...draft.enabledMeasures}..remove(measure);
}
onChanged();
}
}
final class _NumberField extends StatelessWidget {
const _NumberField({
required this.label,
required this.initialValue,
required this.onChanged,
});
final String label;
final num? initialValue;
final ValueChanged<double?> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
initialValue: initialValue?.toString(),
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) => onChanged(double.tryParse(value)),
),
);
}
}
final class _ProgramExerciseDraft {
_ProgramExerciseDraft({
required this.key,
this.existingMetadata,
this.sourceExerciseId,
required this.exerciseNameSnapshot,
this.exerciseDescriptionSnapshot,
this.exerciseImageMediaIdSnapshot,
this.exerciseVideoMediaIdSnapshot,
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
required this.availableScoreSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
required this.enabledMeasures,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.restSecondsOverride,
});
factory _ProgramExerciseDraft.fromExercise(Exercise exercise) {
return _ProgramExerciseDraft(
key: UniqueKey().toString(),
sourceExerciseId: exercise.metadata.id,
exerciseNameSnapshot: exercise.name,
exerciseDescriptionSnapshot: exercise.description,
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseArchivedSnapshot: exercise.archivedAt != null,
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
availableScoreSnapshot: exercise.hasScoreMeasure,
scoreLabelSnapshot: exercise.scoreLabel,
scoreUnitSnapshot: exercise.scoreUnit,
setsCount: 3,
enabledMeasures: exercise.availableMeasures,
);
}
factory _ProgramExerciseDraft.fromProgramExercise(ProgramExercise exercise) {
return _ProgramExerciseDraft(
key: exercise.metadata.id,
existingMetadata: exercise.metadata,
sourceExerciseId: exercise.sourceExerciseId,
exerciseNameSnapshot: exercise.exerciseNameSnapshot,
exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot,
exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot,
availableTimeSnapshot: exercise.availableTimeSnapshot,
availableRepsSnapshot: exercise.availableRepsSnapshot,
availableScoreSnapshot: exercise.availableScoreSnapshot,
scoreLabelSnapshot: exercise.scoreLabelSnapshot,
scoreUnitSnapshot: exercise.scoreUnitSnapshot,
setsCount: exercise.setsCount,
enabledMeasures: {
if (exercise.timeEnabled) WorkoutMeasure.time,
if (exercise.repsEnabled) WorkoutMeasure.reps,
if (exercise.scoreEnabled) WorkoutMeasure.score,
},
targetTimeSeconds: exercise.targetTimeSeconds,
targetReps: exercise.targetReps,
targetScore: exercise.targetScore,
restSecondsOverride: exercise.restSecondsOverride,
);
}
final String key;
final EntityMetadata? existingMetadata;
final String? sourceExerciseId;
final String exerciseNameSnapshot;
final String? exerciseDescriptionSnapshot;
final String? exerciseImageMediaIdSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
final bool availableScoreSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
int setsCount;
Set<WorkoutMeasure> enabledMeasures;
int? targetTimeSeconds;
int? targetReps;
double? targetScore;
int? restSecondsOverride;
ProgramExerciseConfig toConfig() {
return ProgramExerciseConfig(
existingMetadata: existingMetadata,
sourceExerciseId: sourceExerciseId,
exerciseNameSnapshot: exerciseNameSnapshot,
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
availableScoreSnapshot: availableScoreSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
setsCount: setsCount,
enabledMeasures: enabledMeasures,
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
restSecondsOverride: restSecondsOverride,
);
}
}
String _programSummary(Program program) {
final exerciseCount = program.exercises.length;
final setsCount = program.exercises.fold<int>(
0,
(total, exercise) => total + exercise.setsCount,
);
return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''} · '
'$setsCount série${setsCount > 1 ? 's' : ''}';
}
String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) {
final measures = draft.enabledMeasures
.map((measure) {
final unit = draft.scoreUnitSnapshot?.trim();
if (measure == WorkoutMeasure.score &&
unit != null &&
unit.isNotEmpty) {
return 'Score ($unit)';
}
return _measureLabel(measure);
})
.join(' + ');
final rest = draft.restSecondsOverride ?? defaultRestSeconds;
return '${draft.setsCount} séries · $measures · Repos $rest s';
}
String _measureLabel(WorkoutMeasure measure) {
return switch (measure) {
WorkoutMeasure.time => 'Temps',
WorkoutMeasure.reps => 'Répétitions',
WorkoutMeasure.score => 'Score',
};
}
final class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({
required this.title,
this.message,
this.actionLabel,
this.onAction,
});
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
if (message != null) ...[
const SizedBox(height: 8),
Text(message!, textAlign: TextAlign.center),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
FilledButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}

View File

@ -109,6 +109,12 @@ final class _FakeProgramRepository implements ProgramRepository {
@override @override
Future<Program?> findById(String id) async => null; Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override @override
Future<void> save(Program program) async {} Future<void> save(Program program) async {}

View File

@ -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 lexercice 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 = <Exercise>[];
final saved = <Exercise>[];
@override
Future<Exercise?> findById(String id) async {
for (final exercise in exercises) {
if (exercise.metadata.id == id) {
return exercise;
}
}
return null;
}
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override
Future<List<Exercise>> listActive() async => exercises;
@override
Future<void> save(Exercise exercise) async {
saved.add(exercise);
}
}
final class _FakeProgramRepository implements ProgramRepository {
final programs = <Program>[];
@override
Future<Program?> findById(String id) async {
for (final program in programs) {
if (program.metadata.id == id) {
return program;
}
}
return null;
}
@override
Future<List<Program>> listActive() async => programs;
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {
programs.removeWhere((item) => item.metadata.id == program.metadata.id);
programs.add(program);
}
@override
Future<void> save(Program program) async {
programs.add(program);
}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}