feat(ui): écran Composition de séance-modèle

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:59:59 +02:00
parent be02bbfeea
commit 89d89a3005
9 changed files with 1029 additions and 7 deletions

View File

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

View File

@ -67,9 +67,11 @@ abstract interface class ProgramRepository {
abstract interface class WorkoutTemplateRepository {
Future<WorkoutTemplate?> findById(String id);
Future<List<WorkoutTemplate>> listActive();
Future<void> save(WorkoutTemplate template);
Future<void> saveProgram(WorkoutTemplateProgram program);
Future<void> saveOverride(WorkoutTemplateExerciseOverride override);
Future<void> replaceComposition(WorkoutTemplate template, DateTime deletedAt);
}
abstract interface class ActiveSessionRepository {

View File

@ -398,6 +398,14 @@ final class WorkoutTemplateUseCases {
final IdGenerator ids;
final String originDeviceId;
Future<WorkoutTemplate?> findById(String id) {
return templateRepository.findById(id);
}
Future<List<WorkoutTemplate>> listActive() {
return templateRepository.listActive();
}
Future<WorkoutTemplate> create({required String name}) async {
final now = clock.now();
final template = WorkoutTemplate(
@ -408,6 +416,78 @@ final class WorkoutTemplateUseCases {
return template;
}
Future<WorkoutTemplate> saveConfigured({
String? id,
required String name,
required List<WorkoutTemplateProgramConfig> programs,
required List<WorkoutTemplateExerciseOverrideConfig> 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 = <WorkoutTemplateProgram>[];
final programIdsByClientKey = <String, String>{};
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 = <WorkoutTemplateExerciseOverride>[];
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<WorkoutTemplateProgram> 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,

View File

@ -191,21 +191,33 @@ final class DriftWorkoutTemplateRepository
@override
Future<domain.WorkoutTemplate?> 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
? <db.WorkoutTemplateExerciseOverride>[]
: 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<List<domain.WorkoutTemplate>> listActive() async {
final rows =
await (database.select(database.workoutTemplates)
..where((table) => table.deletedAt.isNull())
..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get();
final templates = <domain.WorkoutTemplate>[];
for (final row in rows) {
final template = await findById(row.id);
if (template != null) {
templates.add(template);
}
}
return templates;
}
@override
Future<void> save(domain.WorkoutTemplate template) async {
await database
@ -243,6 +272,81 @@ final class DriftWorkoutTemplateRepository
_workoutTemplateExerciseOverrideCompanion(override),
);
}
@override
Future<void> 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 {

View File

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

View File

@ -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';

View File

@ -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<WorkoutTemplateListScreen> createState() =>
_WorkoutTemplateListScreenState();
}
final class _WorkoutTemplateListScreenState
extends State<WorkoutTemplateListScreen> {
late Future<List<WorkoutTemplate>> _templates;
@override
void initState() {
super.initState();
_templates = widget.workoutTemplateUseCases.listActive();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Séances')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(),
icon: const Icon(Icons.add),
label: const Text('Créer'),
),
body: FutureBuilder<List<WorkoutTemplate>>(
future: _templates,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final templates = snapshot.data ?? const <WorkoutTemplate>[];
if (templates.isEmpty) {
return _CenteredMessage(
title: 'Aucune séance pour le moment',
message: 'Compose une séance à partir de tes programmes.',
actionLabel: 'Créer une séance',
onAction: () => _openForm(),
);
}
return ListView.separated(
itemCount: templates.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final template = templates[index];
return ListTile(
title: Text(template.name),
subtitle: Text(_templateSummary(template)),
trailing: Wrap(
spacing: 8,
children: [
TextButton(onPressed: () {}, child: const Text('Lancer')),
const Icon(Icons.chevron_right),
],
),
onTap: () => _openForm(template: template),
);
},
);
},
),
);
}
void _reload() {
setState(() {
_templates = widget.workoutTemplateUseCases.listActive();
});
}
Future<void> _openForm({WorkoutTemplate? template}) async {
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => WorkoutTemplateFormScreen(
workoutTemplateUseCases: widget.workoutTemplateUseCases,
programUseCases: widget.programUseCases,
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<WorkoutTemplateFormScreen> createState() =>
_WorkoutTemplateFormScreenState();
}
final class _WorkoutTemplateFormScreenState
extends State<WorkoutTemplateFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final List<WorkoutTemplateProgramDraft> _programs;
var _saving = false;
bool get _isEditing => widget.template != null;
@override
void initState() {
super.initState();
final template = widget.template;
_nameController = TextEditingController(text: template?.name);
_programs = [
for (final program
in template?.programs ?? const <WorkoutTemplateProgram>[])
WorkoutTemplateProgramDraft.fromTemplateProgram(
program,
template?.overrides ?? const <WorkoutTemplateExerciseOverride>[],
),
];
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
),
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<void> _addProgram() async {
final program = await Navigator.of(context).push<Program>(
MaterialPageRoute(
builder: (context) =>
ProgramSelectionScreen(programUseCases: widget.programUseCases),
),
);
if (program == null) return;
if (!mounted) return;
final shouldExplainSnapshot = _programs.isEmpty;
setState(() {
_programs.add(WorkoutTemplateProgramDraft.fromProgram(program));
});
if (shouldExplainSnapshot) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une copie de ce programme sera enregistrée dans la séance. '
'Les futures modifications du programme original ne changeront '
'pas cette séance.',
),
),
);
}
}
void _reorderProgram(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _programs.removeAt(oldIndex);
_programs.insert(newIndex, item);
});
}
Future<void> _openProgramDetail(WorkoutTemplateProgramDraft program) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) =>
WorkoutTemplateProgramDetailScreen(program: program),
),
);
if (!mounted) return;
setState(() {});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _saving = true);
try {
await widget.workoutTemplateUseCases.saveConfigured(
id: widget.template?.metadata.id,
name: _nameController.text.trim(),
programs: _programs.map((program) => program.toConfig()).toList(),
overrides: _programs
.expand((program) => program.toOverrides())
.toList(),
);
if (mounted) {
Navigator.of(context).pop(true);
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
final class ProgramSelectionScreen extends StatelessWidget {
const ProgramSelectionScreen({required this.programUseCases, super.key});
final ProgramUseCases programUseCases;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ajouter un programme')),
body: FutureBuilder<List<Program>>(
future: programUseCases.listActive(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final programs = snapshot.data ?? const <Program>[];
if (programs.isEmpty) {
return const _CenteredMessage(
title: 'Aucun programme disponible',
message: 'Crée un programme avant de composer une séance.',
);
}
return ListView.separated(
itemCount: programs.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final program = programs[index];
return ListTile(
title: Text(program.name),
subtitle: Text(_programSummary(program)),
onTap: () => Navigator.of(context).pop(program),
);
},
);
},
),
);
}
}
final class WorkoutTemplateProgramDetailScreen extends StatefulWidget {
const WorkoutTemplateProgramDetailScreen({required this.program, super.key});
final WorkoutTemplateProgramDraft program;
@override
State<WorkoutTemplateProgramDetailScreen> createState() =>
_WorkoutTemplateProgramDetailScreenState();
}
final class _WorkoutTemplateProgramDetailScreenState
extends State<WorkoutTemplateProgramDetailScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.program.programNameSnapshot)),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: widget.program.exercises.length,
itemBuilder: (context, index) {
final exercise = widget.program.exercises[index];
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
exercise.exerciseNameSnapshot,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(_exerciseDraftSummary(exercise)),
const SizedBox(height: 12),
TextFormField(
initialValue:
'${exercise.setsCountOverride ?? exercise.setsCount}',
decoration: const InputDecoration(
labelText: 'Nombre de séries',
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<WorkoutTemplateExerciseOverride> overrides,
) {
final snapshot =
jsonDecode(program.programSnapshotJson) as Map<String, dynamic>;
final exercises = _parseExercises(snapshot);
for (final exercise in exercises) {
for (final override in overrides) {
if (override.workoutTemplateProgramId == program.metadata.id &&
override.snapshotProgramExerciseId == exercise.snapshotExerciseId) {
exercise.existingOverrideMetadata = override.metadata;
exercise.setsCountOverride = override.setsCountOverride;
exercise.targetTimeSecondsOverride =
override.targetTimeSecondsOverride;
exercise.targetRepsOverride = override.targetRepsOverride;
exercise.targetScoreOverride = override.targetScoreOverride;
}
}
}
return WorkoutTemplateProgramDraft(
clientKey: program.metadata.id,
existingMetadata: program.metadata,
sourceProgramId: program.sourceProgramId,
programNameSnapshot: program.programNameSnapshot,
defaultRestSecondsSnapshot: program.defaultRestSecondsSnapshot,
programSnapshotJson: program.programSnapshotJson,
exercises: exercises,
);
}
final String clientKey;
final EntityMetadata? existingMetadata;
final String? sourceProgramId;
final String programNameSnapshot;
final int defaultRestSecondsSnapshot;
final String programSnapshotJson;
final List<WorkoutTemplateExerciseDraft> exercises;
WorkoutTemplateProgramConfig toConfig() {
return WorkoutTemplateProgramConfig(
clientKey: clientKey,
existingMetadata: existingMetadata,
sourceProgramId: sourceProgramId,
programNameSnapshot: programNameSnapshot,
defaultRestSecondsSnapshot: defaultRestSecondsSnapshot,
programSnapshotJson: programSnapshotJson,
);
}
Iterable<WorkoutTemplateExerciseOverrideConfig> toOverrides() sync* {
for (final exercise in exercises) {
if (!exercise.hasOverride) {
continue;
}
yield WorkoutTemplateExerciseOverrideConfig(
existingMetadata: exercise.existingOverrideMetadata,
workoutTemplateProgramClientKey: clientKey,
snapshotProgramExerciseId: exercise.snapshotExerciseId,
setsCountOverride: exercise.setsCountOverride,
targetTimeSecondsOverride: exercise.targetTimeSecondsOverride,
targetRepsOverride: exercise.targetRepsOverride,
targetScoreOverride: exercise.targetScoreOverride,
);
}
}
}
final class WorkoutTemplateExerciseDraft {
WorkoutTemplateExerciseDraft({
required this.snapshotExerciseId,
required this.exerciseNameSnapshot,
required this.setsCount,
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
this.scoreUnitSnapshot,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.existingOverrideMetadata,
this.setsCountOverride,
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
});
final String snapshotExerciseId;
final String exerciseNameSnapshot;
final int setsCount;
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final String? scoreUnitSnapshot;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
EntityMetadata? existingOverrideMetadata;
int? setsCountOverride;
int? targetTimeSecondsOverride;
int? targetRepsOverride;
double? targetScoreOverride;
bool get hasOverride {
return setsCountOverride != null ||
targetTimeSecondsOverride != null ||
targetRepsOverride != null ||
targetScoreOverride != null;
}
}
final class _NumberField extends StatelessWidget {
const _NumberField({
required this.label,
required this.initialValue,
required this.onChanged,
});
final String label;
final num? initialValue;
final ValueChanged<double?> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
initialValue: initialValue?.toString(),
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) => onChanged(double.tryParse(value)),
),
);
}
}
List<WorkoutTemplateExerciseDraft> _parseExercises(
Map<String, dynamic> snapshot,
) {
final rawExercises = snapshot['exercises'] as List<dynamic>? ?? const [];
return rawExercises.map((raw) {
final exercise = raw as Map<String, dynamic>;
return WorkoutTemplateExerciseDraft(
snapshotExerciseId: exercise['id'] as String,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String,
setsCount: exercise['setsCount'] as int,
timeEnabled: exercise['timeEnabled'] as bool,
repsEnabled: exercise['repsEnabled'] as bool,
scoreEnabled: exercise['scoreEnabled'] as bool,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
);
}).toList();
}
String _templateSummary(WorkoutTemplate template) {
final programCount = template.programs.length;
final exerciseCount = template.programs.fold<int>(
0,
(total, program) => total + _exerciseCount(program.programSnapshotJson),
);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _programDraftSummary(WorkoutTemplateProgramDraft program) {
final exerciseCount = program.exercises.length;
return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _programSummary(Program program) {
final exerciseCount = program.exercises.length;
final setsCount = program.exercises.fold<int>(
0,
(total, exercise) => total + exercise.setsCount,
);
return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''} · '
'$setsCount série${setsCount > 1 ? 's' : ''}';
}
String _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) {
final measures = [
if (exercise.timeEnabled) 'Temps',
if (exercise.repsEnabled) 'Répétitions',
if (exercise.scoreEnabled)
exercise.scoreUnitSnapshot == null
? 'Score'
: 'Score (${exercise.scoreUnitSnapshot})',
].join(' + ');
final sets = exercise.setsCountOverride ?? exercise.setsCount;
return '$sets séries · $measures';
}
int _exerciseCount(String programSnapshotJson) {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
}
final class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({
required this.title,
this.message,
this.actionLabel,
this.onAction,
});
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
if (message != null) ...[
const SizedBox(height: 8),
Text(message!, textAlign: TextAlign.center),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
FilledButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}