merge(develop): intègre l'écran Composition de séance-modèle (ticket #8)
Fusionne feature/#8-ecran-seance-modele — 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:
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#8"
|
issueRef: "#8"
|
||||||
version: 4
|
version: 6
|
||||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
updatedAt: 1784301388397
|
updatedAt: 1784307587164
|
||||||
---
|
---
|
||||||
Référence : mémoire "gametime-ux-conception" section 3 — décision produit explicitement validée par l'utilisateur. Au premier ajout d'un programme à la séance, afficher le message expliquant qu'une copie est enregistrée et que les futures modifications du programme source ne changeront pas cette séance. Les seuls champs éditables depuis cette surface sont : nombre de séries, et valeurs cibles numériques des mesures déjà actives (temps cible, répétitions cible, score cible). Ne pas permettre l'ajout/suppression/réordonnancement d'exercices ni le changement des mesures activées ici — ça reste au niveau du programme source (ticket #7).
|
Référence : mémoire "gametime-ux-conception" section 3 — décision produit explicitement validée par l'utilisateur. Au premier ajout d'un programme à la séance, afficher le message expliquant qu'une copie est enregistrée et que les futures modifications du programme source ne changeront pas cette séance. Les seuls champs éditables depuis cette surface sont : nombre de séries, et valeurs cibles numériques des mesures déjà actives (temps cible, répétitions cible, score cible). Ne pas permettre l'ajout/suppression/réordonnancement d'exercices ni le changement des mesures activées ici — ça reste au niveau du programme source (ticket #7).
|
||||||
@ -2,7 +2,7 @@
|
|||||||
id: "8f24db05-5d56-4324-8bef-68e4837e0e3b"
|
id: "8f24db05-5d56-4324-8bef-68e4837e0e3b"
|
||||||
number: 8
|
number: 8
|
||||||
title: "[DevFrontend] Écran Composition de séance-modèle"
|
title: "[DevFrontend] Écran Composition de séance-modèle"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#4","kind":"dependsOn"},{"target":"#7","kind":"dependsOn"}]
|
links: [{"target":"#4","kind":"dependsOn"},{"target":"#7","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"}
|
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
createdAt: 1784301303492
|
createdAt: 1784301303492
|
||||||
updatedAt: 1784301388397
|
updatedAt: 1784307587164
|
||||||
version: 4
|
version: 6
|
||||||
---
|
---
|
||||||
Assemblage nommé et réordonnable de un ou plusieurs programmes (copie/snapshot au moment de l'ajout, avec message explicite à l'utilisateur sur l'indépendance vis-à-vis du programme source). Overrides autorisés uniquement : nombre de séries et valeurs cibles numériques des mesures déjà actives par exercice — pas de changement de mesures actives, pas de réordonnancement/ajout/suppression d'exercice depuis la séance. Cf. mémoire "gametime-ux-conception" section 3 (décision produit tranchée avec l'utilisateur).
|
Assemblage nommé et réordonnable de un ou plusieurs programmes (copie/snapshot au moment de l'ajout, avec message explicite à l'utilisateur sur l'indépendance vis-à-vis du programme source). Overrides autorisés uniquement : nombre de séries et valeurs cibles numériques des mesures déjà actives par exercice — pas de changement de mesures actives, pas de réordonnancement/ajout/suppression d'exercice depuis la séance. Cf. mémoire "gametime-ux-conception" section 3 (décision produit tranchée avec l'utilisateur).
|
||||||
@ -89,13 +89,13 @@
|
|||||||
"issueRef": "#8",
|
"issueRef": "#8",
|
||||||
"path": "8",
|
"path": "8",
|
||||||
"title": "[DevFrontend] Écran Composition de séance-modèle",
|
"title": "[DevFrontend] Écran Composition de séance-modèle",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [
|
"assignedAgentIds": [
|
||||||
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
|
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
|
||||||
],
|
],
|
||||||
"updatedAt": 1784301388397
|
"updatedAt": 1784307587164
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#9",
|
"issueRef": "#9",
|
||||||
|
|||||||
@ -7,12 +7,14 @@ final class AppBootstrap {
|
|||||||
required this.exerciseUseCases,
|
required this.exerciseUseCases,
|
||||||
required this.mediaUseCases,
|
required this.mediaUseCases,
|
||||||
required this.programUseCases,
|
required this.programUseCases,
|
||||||
|
required this.workoutTemplateUseCases,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppDatabase database;
|
final AppDatabase database;
|
||||||
final ExerciseUseCases exerciseUseCases;
|
final ExerciseUseCases exerciseUseCases;
|
||||||
final MediaUseCases mediaUseCases;
|
final MediaUseCases mediaUseCases;
|
||||||
final ProgramUseCases programUseCases;
|
final ProgramUseCases programUseCases;
|
||||||
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||||
|
|
||||||
static Future<AppBootstrap> create() async {
|
static Future<AppBootstrap> create() async {
|
||||||
final database = AppDatabase.open();
|
final database = AppDatabase.open();
|
||||||
@ -20,6 +22,7 @@ final class AppBootstrap {
|
|||||||
final exerciseRepository = DriftExerciseRepository(database);
|
final exerciseRepository = DriftExerciseRepository(database);
|
||||||
final mediaRepository = DriftMediaAssetRepository(database);
|
final mediaRepository = DriftMediaAssetRepository(database);
|
||||||
final programRepository = DriftProgramRepository(database);
|
final programRepository = DriftProgramRepository(database);
|
||||||
|
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||||
final ids = LocalIdGenerator();
|
final ids = LocalIdGenerator();
|
||||||
const clock = SystemClock();
|
const clock = SystemClock();
|
||||||
const originDeviceId = 'local-device';
|
const originDeviceId = 'local-device';
|
||||||
@ -47,6 +50,13 @@ final class AppBootstrap {
|
|||||||
ids: ids,
|
ids: ids,
|
||||||
originDeviceId: originDeviceId,
|
originDeviceId: originDeviceId,
|
||||||
),
|
),
|
||||||
|
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||||||
|
templateRepository: templateRepository,
|
||||||
|
programRepository: programRepository,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
originDeviceId: originDeviceId,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -67,9 +67,11 @@ abstract interface class ProgramRepository {
|
|||||||
|
|
||||||
abstract interface class WorkoutTemplateRepository {
|
abstract interface class WorkoutTemplateRepository {
|
||||||
Future<WorkoutTemplate?> findById(String id);
|
Future<WorkoutTemplate?> findById(String id);
|
||||||
|
Future<List<WorkoutTemplate>> listActive();
|
||||||
Future<void> save(WorkoutTemplate template);
|
Future<void> save(WorkoutTemplate template);
|
||||||
Future<void> saveProgram(WorkoutTemplateProgram program);
|
Future<void> saveProgram(WorkoutTemplateProgram program);
|
||||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override);
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override);
|
||||||
|
Future<void> replaceComposition(WorkoutTemplate template, DateTime deletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract interface class ActiveSessionRepository {
|
abstract interface class ActiveSessionRepository {
|
||||||
|
|||||||
@ -398,6 +398,14 @@ final class WorkoutTemplateUseCases {
|
|||||||
final IdGenerator ids;
|
final IdGenerator ids;
|
||||||
final String originDeviceId;
|
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 {
|
Future<WorkoutTemplate> create({required String name}) async {
|
||||||
final now = clock.now();
|
final now = clock.now();
|
||||||
final template = WorkoutTemplate(
|
final template = WorkoutTemplate(
|
||||||
@ -408,6 +416,78 @@ final class WorkoutTemplateUseCases {
|
|||||||
return template;
|
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({
|
Future<WorkoutTemplateProgram> addProgramSnapshot({
|
||||||
required String workoutTemplateId,
|
required String workoutTemplateId,
|
||||||
required String programId,
|
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 {
|
final class ActiveWorkoutSessionUseCases {
|
||||||
const ActiveWorkoutSessionUseCases({
|
const ActiveWorkoutSessionUseCases({
|
||||||
required this.sessionRepository,
|
required this.sessionRepository,
|
||||||
|
|||||||
@ -191,21 +191,33 @@ final class DriftWorkoutTemplateRepository
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<domain.WorkoutTemplate?> findById(String id) async {
|
Future<domain.WorkoutTemplate?> findById(String id) async {
|
||||||
final row = await (database.select(
|
final row =
|
||||||
database.workoutTemplates,
|
await (database.select(
|
||||||
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
database.workoutTemplates,
|
||||||
|
)..where((table) => table.id.equals(id) & table.deletedAt.isNull()))
|
||||||
|
.getSingleOrNull();
|
||||||
if (row == null) {
|
if (row == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final programs =
|
final programs =
|
||||||
await (database.select(database.workoutTemplatePrograms)
|
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)]))
|
..orderBy([(table) => OrderingTerm.asc(table.position)]))
|
||||||
.get();
|
.get();
|
||||||
final programIds = programs.map((program) => program.id).toSet();
|
final programIds = programs.map((program) => program.id).toSet();
|
||||||
final overrides = await (database.select(
|
final overrides = programIds.isEmpty
|
||||||
database.workoutTemplateExerciseOverrides,
|
? <db.WorkoutTemplateExerciseOverride>[]
|
||||||
)..where((table) => table.workoutTemplateProgramId.isIn(programIds))).get();
|
: await (database.select(database.workoutTemplateExerciseOverrides)
|
||||||
|
..where(
|
||||||
|
(table) =>
|
||||||
|
table.workoutTemplateProgramId.isIn(programIds) &
|
||||||
|
table.deletedAt.isNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
return _workoutTemplateFromRow(
|
return _workoutTemplateFromRow(
|
||||||
row,
|
row,
|
||||||
programs.map(_workoutTemplateProgramFromRow).toList(),
|
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
|
@override
|
||||||
Future<void> save(domain.WorkoutTemplate template) async {
|
Future<void> save(domain.WorkoutTemplate template) async {
|
||||||
await database
|
await database
|
||||||
@ -243,6 +272,81 @@ final class DriftWorkoutTemplateRepository
|
|||||||
_workoutTemplateExerciseOverrideCompanion(override),
|
_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 {
|
final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../application/app_bootstrap.dart';
|
import '../application/app_bootstrap.dart';
|
||||||
import 'exercise_library_screen.dart';
|
import 'exercise_library_screen.dart';
|
||||||
import 'program_screen.dart';
|
import 'program_screen.dart';
|
||||||
|
import 'workout_template_screen.dart';
|
||||||
|
|
||||||
final class HomeScreen extends StatelessWidget {
|
final class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({required this.bootstrap, super.key});
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export 'game_time_app.dart';
|
|||||||
export 'exercise_library_screen.dart';
|
export 'exercise_library_screen.dart';
|
||||||
export 'home_screen.dart';
|
export 'home_screen.dart';
|
||||||
export 'program_screen.dart';
|
export 'program_screen.dart';
|
||||||
|
export 'workout_template_screen.dart';
|
||||||
|
|||||||
722
lib/presentation/workout_template_screen.dart
Normal file
722
lib/presentation/workout_template_screen.dart
Normal 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!)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -95,6 +95,15 @@ final class _FakeWorkoutTemplateRepository
|
|||||||
@override
|
@override
|
||||||
Future<WorkoutTemplate?> findById(String id) async => null;
|
Future<WorkoutTemplate?> findById(String id) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> replaceComposition(
|
||||||
|
WorkoutTemplate template,
|
||||||
|
DateTime deletedAt,
|
||||||
|
) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(WorkoutTemplate template) async {}
|
Future<void> save(WorkoutTemplate template) async {}
|
||||||
|
|
||||||
|
|||||||
42
test/presentation/workout_template_screen_test.dart
Normal file
42
test/presentation/workout_template_screen_test.dart
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gametime/presentation/presentation.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'la vue programme de séance ne permet pas de changer les mesures actives',
|
||||||
|
(tester) async {
|
||||||
|
final program = WorkoutTemplateProgramDraft(
|
||||||
|
clientKey: 'program-1',
|
||||||
|
programNameSnapshot: 'Jambes',
|
||||||
|
defaultRestSecondsSnapshot: 60,
|
||||||
|
programSnapshotJson: '{}',
|
||||||
|
exercises: [
|
||||||
|
WorkoutTemplateExerciseDraft(
|
||||||
|
snapshotExerciseId: 'exercise-1',
|
||||||
|
exerciseNameSnapshot: 'Squat',
|
||||||
|
setsCount: 3,
|
||||||
|
timeEnabled: true,
|
||||||
|
repsEnabled: true,
|
||||||
|
scoreEnabled: false,
|
||||||
|
targetTimeSeconds: 45,
|
||||||
|
targetReps: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(CheckboxListTile), findsNothing);
|
||||||
|
expect(find.text('Mesures à suivre'), findsNothing);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).first, '5');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(program.exercises.single.setsCountOverride, 5);
|
||||||
|
expect(find.text('5 séries · Temps + Répétitions'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user