merge(develop): intègre l'écran Création/édition de programme (ticket #7)
Fusionne feature/#7-ecran-programme — 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:
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#7"
|
issueRef: "#7"
|
||||||
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: 1784301385567
|
updatedAt: 1784307099507
|
||||||
---
|
---
|
||||||
Référence : mémoire "gametime-ux-conception" section 2. Repos affiché comme "Repos après cette série", attaché à la série/l'exercice précédent pour suivre le réordonnancement (poignée de déplacement). Pas de repos après la toute dernière série du programme. Ligne résumé attendue sur chaque carte exercice : "X séries · [mesures actives] · Repos Ys".
|
Référence : mémoire "gametime-ux-conception" section 2. Repos affiché comme "Repos après cette série", attaché à la série/l'exercice précédent pour suivre le réordonnancement (poignée de déplacement). Pas de repos après la toute dernière série du programme. Ligne résumé attendue sur chaque carte exercice : "X séries · [mesures actives] · Repos Ys".
|
||||||
@ -2,7 +2,7 @@
|
|||||||
id: "f955dede-b254-49da-95bb-f63adca76d05"
|
id: "f955dede-b254-49da-95bb-f63adca76d05"
|
||||||
number: 7
|
number: 7
|
||||||
title: "[DevFrontend] Écran Création/édition de programme"
|
title: "[DevFrontend] Écran Création/édition de programme"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#4","kind":"dependsOn"},{"target":"#6","kind":"dependsOn"}]
|
links: [{"target":"#4","kind":"dependsOn"},{"target":"#6","kind":"dependsOn"}]
|
||||||
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}
|
|||||||
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
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: 1784301300945
|
createdAt: 1784301300945
|
||||||
updatedAt: 1784301385567
|
updatedAt: 1784307099507
|
||||||
version: 4
|
version: 6
|
||||||
---
|
---
|
||||||
Ajout d'exercices depuis la bibliothèque (recherche, badges de mesures). Cartes réordonnables par exercice. Configuration par exercice : nombre de séries, mesures à suivre (sous-ensemble de celles autorisées par l'exercice), cible par mesure activée, repos après chaque série (hérite du défaut programme, surchargeable). Gestion du cas "exercice archivé/supprimé de la bibliothèque". Cf. mémoire "gametime-ux-conception" section 2.
|
Ajout d'exercices depuis la bibliothèque (recherche, badges de mesures). Cartes réordonnables par exercice. Configuration par exercice : nombre de séries, mesures à suivre (sous-ensemble de celles autorisées par l'exercice), cible par mesure activée, repos après chaque série (hérite du défaut programme, surchargeable). Gestion du cas "exercice archivé/supprimé de la bibliothèque". Cf. mémoire "gametime-ux-conception" section 2.
|
||||||
@ -77,13 +77,13 @@
|
|||||||
"issueRef": "#7",
|
"issueRef": "#7",
|
||||||
"path": "7",
|
"path": "7",
|
||||||
"title": "[DevFrontend] Écran Création/édition de programme",
|
"title": "[DevFrontend] Écran Création/édition de programme",
|
||||||
"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": 1784301385567
|
"updatedAt": 1784307099507
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#8",
|
"issueRef": "#8",
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
lib/presentation/home_screen.dart
Normal file
48
lib/presentation/home_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
772
lib/presentation/program_screen.dart
Normal file
772
lib/presentation/program_screen.dart
Normal 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!)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|
||||||
|
|||||||
198
test/presentation/program_screen_test.dart
Normal file
198
test/presentation/program_screen_test.dart
Normal 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 l’exercice source',
|
||||||
|
(tester) async {
|
||||||
|
final exerciseRepository = _FakeExerciseRepository()
|
||||||
|
..exercises.add(
|
||||||
|
Exercise(
|
||||||
|
metadata: _metadata('exercise-1'),
|
||||||
|
name: 'Pompes',
|
||||||
|
hasTimeMeasure: false,
|
||||||
|
hasRepsMeasure: true,
|
||||||
|
hasScoreMeasure: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ProgramFormScreen(
|
||||||
|
programUseCases: _programUseCases(
|
||||||
|
_FakeProgramRepository(),
|
||||||
|
exerciseRepository,
|
||||||
|
),
|
||||||
|
exerciseUseCases: _exerciseUseCases(exerciseRepository),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Ajouter un exercice'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Pompes'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.widgetWithText(CheckboxListTile, 'Répétitions'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.widgetWithText(CheckboxListTile, 'Temps'), findsNothing);
|
||||||
|
expect(find.widgetWithText(CheckboxListTile, 'Score'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
|
||||||
|
return ExerciseUseCases(
|
||||||
|
repository: repository,
|
||||||
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
|
ids: _FakeIds(),
|
||||||
|
originDeviceId: 'device-1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgramUseCases _programUseCases(
|
||||||
|
_FakeProgramRepository programRepository,
|
||||||
|
_FakeExerciseRepository exerciseRepository,
|
||||||
|
) {
|
||||||
|
return ProgramUseCases(
|
||||||
|
programRepository: programRepository,
|
||||||
|
exerciseRepository: exerciseRepository,
|
||||||
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
|
ids: _FakeIds(),
|
||||||
|
originDeviceId: 'device-1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityMetadata _metadata(String id) {
|
||||||
|
return EntityMetadata(
|
||||||
|
id: id,
|
||||||
|
createdAt: DateTime.utc(2026, 7, 17),
|
||||||
|
updatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
originDeviceId: 'device-1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeClock implements Clock {
|
||||||
|
const _FakeClock(this.value);
|
||||||
|
|
||||||
|
final DateTime value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
DateTime now() => value;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeIds implements IdGenerator {
|
||||||
|
var _next = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String newId() {
|
||||||
|
_next += 1;
|
||||||
|
return 'id-$_next';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeExerciseRepository implements ExerciseRepository {
|
||||||
|
final exercises = <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 {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user