merge(develop): intègre l'écran Exécution de séance (ticket #9)
Fusionne feature/#9-ecran-execution-seance — analyze propre, 15/15 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: "#9"
|
||||
version: 4
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784301392430
|
||||
updatedAt: 1784308091100
|
||||
---
|
||||
Référence : mémoire "gametime-ux-conception" section 4. Écran le plus sensible du produit : grandes zones tactiles (44-48px mini), utilisable à l'effort, une main. Hiérarchie de saisie si mesures cumulées : Temps (bloc principal) > Répétitions (stepper) > Score (champ + unité, clavier adapté au type). Écran "Repos" séparé avec +15s/-15s et "Ignorer le repos". Pause : "Quitter et sauvegarder" comme action sûre recommandée, "Abandonner" comme action destructive confirmée séparément. Reprise doit fonctionner même après fermeture complète de l'app (bandeau "Séance en cours" au retour). Fin de séance écrit dans WorkoutHistory (ticket #4) avec temps total + scores par série.
|
||||
@ -2,7 +2,7 @@
|
||||
id: "311cfb2b-0a09-4c18-ba66-4053994bc109"
|
||||
number: 9
|
||||
title: "[DevFrontend] Écran Exécution de séance"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "critical"
|
||||
sprint: null
|
||||
links: [{"target":"#4","kind":"dependsOn"},{"target":"#8","kind":"dependsOn"}]
|
||||
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}
|
||||
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
createdAt: 1784301307130
|
||||
updatedAt: 1784301392430
|
||||
version: 4
|
||||
updatedAt: 1784308091100
|
||||
version: 6
|
||||
---
|
||||
Surface critique optimisée pour l'usage à l'effort (grandes zones tactiles, une main). En-tête temps écoulé + pause. Progression Programme/Exercice/Série. Saisie hiérarchisée quand mesures cumulées (Temps > Répétitions > Score avec unité). Écran "Repos" dédié entre séries avec ajustement ±15s et "Ignorer le repos". Pause avec "Quitter et sauvegarder" vs "Abandonner" (destructif confirmé). Reprise après interruption/fermeture app, robuste à l'arrière-plan (recalcul depuis horodatages, pas depuis un état mémoire). Écran de fin de séance (récap + relance) → écriture dans l'historique. Cf. mémoire "gametime-ux-conception" section 4.
|
||||
@ -101,13 +101,13 @@
|
||||
"issueRef": "#9",
|
||||
"path": "9",
|
||||
"title": "[DevFrontend] Écran Exécution de séance",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "critical",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
|
||||
],
|
||||
"updatedAt": 1784301392430
|
||||
"updatedAt": 1784308091100
|
||||
},
|
||||
{
|
||||
"issueRef": "#10",
|
||||
|
||||
@ -8,6 +8,8 @@ final class AppBootstrap {
|
||||
required this.mediaUseCases,
|
||||
required this.programUseCases,
|
||||
required this.workoutTemplateUseCases,
|
||||
required this.activeWorkoutSessionUseCases,
|
||||
required this.closeWorkoutSessionUseCase,
|
||||
});
|
||||
|
||||
final AppDatabase database;
|
||||
@ -15,6 +17,8 @@ final class AppBootstrap {
|
||||
final MediaUseCases mediaUseCases;
|
||||
final ProgramUseCases programUseCases;
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||
|
||||
static Future<AppBootstrap> create() async {
|
||||
final database = AppDatabase.open();
|
||||
@ -23,6 +27,8 @@ final class AppBootstrap {
|
||||
final mediaRepository = DriftMediaAssetRepository(database);
|
||||
final programRepository = DriftProgramRepository(database);
|
||||
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||
final activeSessionRepository = DriftActiveSessionRepository(database);
|
||||
final historyRepository = DriftWorkoutHistoryRepository(database);
|
||||
final ids = LocalIdGenerator();
|
||||
const clock = SystemClock();
|
||||
const originDeviceId = 'local-device';
|
||||
@ -57,6 +63,20 @@ final class AppBootstrap {
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
activeWorkoutSessionUseCases: ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: activeSessionRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeSessionRepository,
|
||||
historyRepository: historyRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -76,6 +76,7 @@ abstract interface class WorkoutTemplateRepository {
|
||||
|
||||
abstract interface class ActiveSessionRepository {
|
||||
Future<ActiveWorkoutSession?> findById(String id);
|
||||
Future<ActiveWorkoutSession?> findOpen();
|
||||
Future<void> save(ActiveWorkoutSession session);
|
||||
Future<void> saveSetResult(ActiveSetResult result);
|
||||
Future<void> saveRestState(ActiveRestState restState);
|
||||
|
||||
@ -590,6 +590,8 @@ final class ActiveWorkoutSessionUseCases {
|
||||
final IdGenerator ids;
|
||||
final String originDeviceId;
|
||||
|
||||
Future<ActiveWorkoutSession?> findOpen() => sessionRepository.findOpen();
|
||||
|
||||
Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
|
||||
final template = await templateRepository.findById(templateId);
|
||||
if (template == null) {
|
||||
@ -659,6 +661,97 @@ final class ActiveWorkoutSessionUseCases {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<ActiveSetResult> recordCurrentSetResult({
|
||||
required String sessionId,
|
||||
required String programSnapshotId,
|
||||
required String exerciseSnapshotId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
int? actualTimeMs,
|
||||
int? actualReps,
|
||||
double? actualScore,
|
||||
String? scoreLabelSnapshot,
|
||||
String? scoreUnitSnapshot,
|
||||
}) async {
|
||||
final now = clock.now();
|
||||
final result = ActiveSetResult(
|
||||
metadata: _newMetadata(ids, originDeviceId, now),
|
||||
activeWorkoutSessionId: sessionId,
|
||||
programSnapshotId: programSnapshotId,
|
||||
exerciseSnapshotId: exerciseSnapshotId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
completedAt: now,
|
||||
actualTimeMs: actualTimeMs,
|
||||
actualReps: actualReps,
|
||||
actualScore: actualScore,
|
||||
scoreLabelSnapshot: scoreLabelSnapshot,
|
||||
scoreUnitSnapshot: scoreUnitSnapshot,
|
||||
);
|
||||
await sessionRepository.saveSetResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<ActiveWorkoutSession> updateProgress({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final session = await _requiredSession(sessionId);
|
||||
final updated = session.copyWith(
|
||||
currentProgramIndex: programIndex,
|
||||
currentExerciseIndex: exerciseIndex,
|
||||
currentSetIndex: setIndex,
|
||||
metadata: session.metadata.touch(clock.now()),
|
||||
);
|
||||
await sessionRepository.save(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<ActiveWorkoutSession> quitAndSave(String sessionId) async {
|
||||
final session = await _requiredSession(sessionId);
|
||||
final now = clock.now();
|
||||
final saved = session.copyWith(
|
||||
status: ActiveWorkoutStatus.savedExit,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
|
||||
metadata: session.metadata.touch(now),
|
||||
);
|
||||
await sessionRepository.save(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
Future<ActiveWorkoutSession> abandon(String sessionId) async {
|
||||
final session = await _requiredSession(sessionId);
|
||||
final now = clock.now();
|
||||
final abandoned = session.copyWith(
|
||||
status: ActiveWorkoutStatus.abandoned,
|
||||
endedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
|
||||
metadata: session.metadata.touch(now),
|
||||
);
|
||||
await sessionRepository.save(abandoned);
|
||||
return abandoned;
|
||||
}
|
||||
|
||||
Future<ActiveWorkoutSession> complete(String sessionId) async {
|
||||
final session = await _requiredSession(sessionId);
|
||||
final now = clock.now();
|
||||
final completed = session.copyWith(
|
||||
status: ActiveWorkoutStatus.completed,
|
||||
endedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
|
||||
metadata: session.metadata.touch(now),
|
||||
);
|
||||
await sessionRepository.save(completed);
|
||||
return completed;
|
||||
}
|
||||
|
||||
Future<ActiveRestState> startRestAfterSet({
|
||||
required String sessionId,
|
||||
required int afterProgramIndex,
|
||||
|
||||
@ -362,6 +362,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
return row == null ? null : _activeWorkoutSessionFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveWorkoutSession?> findOpen() async {
|
||||
final row =
|
||||
await (database.select(database.activeWorkoutSessions)
|
||||
..where(
|
||||
(table) =>
|
||||
table.deletedAt.isNull() &
|
||||
table.status.isIn(['running', 'paused', 'savedExit']),
|
||||
)
|
||||
..orderBy([(table) => OrderingTerm.desc(table.updatedAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _activeWorkoutSessionFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(domain.ActiveWorkoutSession session) async {
|
||||
await database
|
||||
|
||||
@ -1,21 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/app_bootstrap.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'exercise_library_screen.dart';
|
||||
import 'program_screen.dart';
|
||||
import 'workout_execution_screen.dart';
|
||||
import 'workout_template_screen.dart';
|
||||
|
||||
final class HomeScreen extends StatelessWidget {
|
||||
final class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({required this.bootstrap, super.key});
|
||||
|
||||
final AppBootstrap bootstrap;
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
final class _HomeScreenState extends State<HomeScreen> {
|
||||
late Future<ActiveWorkoutSession?> _openSession;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('GameTime')),
|
||||
body: ListView(
|
||||
children: [
|
||||
FutureBuilder<ActiveWorkoutSession?>(
|
||||
future: _openSession,
|
||||
builder: (context, snapshot) {
|
||||
final session = snapshot.data;
|
||||
if (session == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final plan = WorkoutExecutionPlan.fromSession(session);
|
||||
final position = ExecutionPosition(
|
||||
programIndex: session.currentProgramIndex,
|
||||
exerciseIndex: session.currentExerciseIndex,
|
||||
setIndex: session.currentSetIndex,
|
||||
);
|
||||
final exercise = plan.exerciseAt(position);
|
||||
return MaterialBanner(
|
||||
content: Text(
|
||||
'Séance en cours — Reprendre · ${exercise.name}, '
|
||||
'série ${position.setIndex + 1}/${exercise.setsCount}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => _resume(session),
|
||||
child: const Text('Reprendre'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.fitness_center),
|
||||
title: const Text('Exercices'),
|
||||
@ -23,8 +66,8 @@ final class HomeScreen extends StatelessWidget {
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExerciseLibraryScreen(
|
||||
exerciseUseCases: bootstrap.exerciseUseCases,
|
||||
mediaUseCases: bootstrap.mediaUseCases,
|
||||
exerciseUseCases: widget.bootstrap.exerciseUseCases,
|
||||
mediaUseCases: widget.bootstrap.mediaUseCases,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -36,8 +79,8 @@ final class HomeScreen extends StatelessWidget {
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProgramListScreen(
|
||||
programUseCases: bootstrap.programUseCases,
|
||||
exerciseUseCases: bootstrap.exerciseUseCases,
|
||||
programUseCases: widget.bootstrap.programUseCases,
|
||||
exerciseUseCases: widget.bootstrap.exerciseUseCases,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -49,8 +92,11 @@ final class HomeScreen extends StatelessWidget {
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkoutTemplateListScreen(
|
||||
workoutTemplateUseCases: bootstrap.workoutTemplateUseCases,
|
||||
programUseCases: bootstrap.programUseCases,
|
||||
workoutTemplateUseCases:
|
||||
widget.bootstrap.workoutTemplateUseCases,
|
||||
programUseCases: widget.bootstrap.programUseCases,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -59,4 +105,20 @@ final class HomeScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resume(ActiveWorkoutSession session) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,4 +2,5 @@ export 'game_time_app.dart';
|
||||
export 'exercise_library_screen.dart';
|
||||
export 'home_screen.dart';
|
||||
export 'program_screen.dart';
|
||||
export 'workout_execution_screen.dart';
|
||||
export 'workout_template_screen.dart';
|
||||
|
||||
667
lib/presentation/workout_execution_screen.dart
Normal file
667
lib/presentation/workout_execution_screen.dart
Normal file
@ -0,0 +1,667 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
|
||||
enum WorkoutExecutionMode { active, rest, paused, finished }
|
||||
|
||||
final class WorkoutExecutionScreen extends StatefulWidget {
|
||||
const WorkoutExecutionScreen({
|
||||
required this.initialSession,
|
||||
required this.activeUseCases,
|
||||
required this.closeUseCase,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ActiveWorkoutSession initialSession;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
|
||||
@override
|
||||
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
|
||||
}
|
||||
|
||||
final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
late ActiveWorkoutSession _session;
|
||||
late final WorkoutExecutionPlan _plan;
|
||||
late WorkoutExecutionMode _mode;
|
||||
Timer? _ticker;
|
||||
Timer? _restTicker;
|
||||
var _reps = 0;
|
||||
final _scoreController = TextEditingController();
|
||||
var _remainingRestSeconds = 0;
|
||||
|
||||
ExecutionPosition get _position => ExecutionPosition(
|
||||
programIndex: _session.currentProgramIndex,
|
||||
exerciseIndex: _session.currentExerciseIndex,
|
||||
setIndex: _session.currentSetIndex,
|
||||
);
|
||||
|
||||
ExecutionExercise get _exercise => _plan.exerciseAt(_position);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_session = widget.initialSession;
|
||||
_plan = WorkoutExecutionPlan.fromSession(_session);
|
||||
_mode = _session.status == ActiveWorkoutStatus.running
|
||||
? WorkoutExecutionMode.active
|
||||
: WorkoutExecutionMode.paused;
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
_restTicker?.cancel();
|
||||
_scoreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_plan.name),
|
||||
actions: [TextButton(onPressed: _pause, child: const Text('Pause'))],
|
||||
),
|
||||
body: switch (_mode) {
|
||||
WorkoutExecutionMode.active => _buildActive(),
|
||||
WorkoutExecutionMode.rest => _buildRest(),
|
||||
WorkoutExecutionMode.paused => _buildPaused(),
|
||||
WorkoutExecutionMode.finished => _buildFinished(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActive() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Header(
|
||||
elapsedLabel: _formatDuration(
|
||||
Duration(
|
||||
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(
|
||||
_session,
|
||||
),
|
||||
),
|
||||
),
|
||||
progressLabel: _plan.progressLabel(_position),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 16),
|
||||
SetMeasureInput(
|
||||
exercise: _exercise,
|
||||
reps: _reps,
|
||||
scoreController: _scoreController,
|
||||
onRepsChanged: (value) => setState(() => _reps = value),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => _finishSet(skipped: false),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)),
|
||||
child: const Text('Terminer la série'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () => _finishSet(skipped: true),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
child: const Text('Passer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRest() {
|
||||
final next = _plan.nextPosition(_position);
|
||||
final nextExercise = next == null ? null : _plan.exerciseAt(next);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Repos avant la prochaine série'),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Text(
|
||||
_formatDuration(Duration(seconds: _remainingRestSeconds)),
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
),
|
||||
if (next != null && nextExercise != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Ensuite : ${nextExercise.name} · '
|
||||
'Série ${next.setIndex + 1}/${nextExercise.setsCount}',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() {
|
||||
_remainingRestSeconds = (_remainingRestSeconds - 15)
|
||||
.clamp(0, 9999)
|
||||
.toInt();
|
||||
}),
|
||||
child: const Text('-15 s'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() {
|
||||
_remainingRestSeconds += 15;
|
||||
}),
|
||||
child: const Text('+15 s'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _skipRest,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
child: const Text('Ignorer le repos'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: _pause, child: const Text('Pause')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaused() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Pause', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _resume,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
),
|
||||
child: const Text('Reprendre'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _quitAndSave,
|
||||
child: const Text('Quitter et sauvegarder'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _confirmAbandon,
|
||||
child: const Text('Abandonner la séance'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFinished() {
|
||||
final elapsed = Duration(
|
||||
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Séance terminée',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Temps total : ${_formatDuration(elapsed)}'),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(onPressed: () {}, child: const Text('Voir le détail')),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Relancer cette séance'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
},
|
||||
child: const Text('Retour à l’accueil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pause() async {
|
||||
_restTicker?.cancel();
|
||||
_session = await widget.activeUseCases.pause(_session.metadata.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.paused);
|
||||
}
|
||||
|
||||
Future<void> _resume() async {
|
||||
_session = await widget.activeUseCases.resume(_session.metadata.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.active);
|
||||
}
|
||||
|
||||
Future<void> _quitAndSave() async {
|
||||
await widget.activeUseCases.quitAndSave(_session.metadata.id);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _confirmAbandon() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Abandonner la séance ?'),
|
||||
content: const Text('Cette action est définitive.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Abandonner'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await widget.activeUseCases.abandon(_session.metadata.id);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _finishSet({required bool skipped}) async {
|
||||
final score = double.tryParse(_scoreController.text.trim());
|
||||
await widget.activeUseCases.recordCurrentSetResult(
|
||||
sessionId: _session.metadata.id,
|
||||
programSnapshotId: _plan.programAt(_position).id,
|
||||
exerciseSnapshotId: _exercise.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
actualReps: skipped ? null : (_exercise.repsEnabled ? _reps : null),
|
||||
actualScore: skipped ? null : (_exercise.scoreEnabled ? score : null),
|
||||
scoreUnitSnapshot: _exercise.scoreUnit,
|
||||
actualTimeMs: skipped ? null : (_exercise.timeEnabled ? 0 : null),
|
||||
);
|
||||
|
||||
final next = _plan.nextPosition(_position);
|
||||
if (next == null) {
|
||||
await _complete();
|
||||
return;
|
||||
}
|
||||
final shouldRest = _plan.shouldShowRestAfter(_position);
|
||||
if (shouldRest) {
|
||||
await widget.activeUseCases.startRestAfterSet(
|
||||
sessionId: _session.metadata.id,
|
||||
afterProgramIndex: _position.programIndex,
|
||||
afterExerciseIndex: _position.exerciseIndex,
|
||||
afterSetIndex: _position.setIndex,
|
||||
plannedRestSeconds: _exercise.restSeconds,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_remainingRestSeconds = _exercise.restSeconds;
|
||||
_startRestTicker(next);
|
||||
setState(() => _mode = WorkoutExecutionMode.rest);
|
||||
return;
|
||||
}
|
||||
await _moveTo(next);
|
||||
}
|
||||
|
||||
void _startRestTicker(ExecutionPosition next) {
|
||||
_restTicker?.cancel();
|
||||
_restTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
if (_remainingRestSeconds <= 1) {
|
||||
timer.cancel();
|
||||
unawaited(_moveTo(next));
|
||||
} else {
|
||||
setState(() => _remainingRestSeconds -= 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _skipRest() async {
|
||||
_restTicker?.cancel();
|
||||
final next = _plan.nextPosition(_position);
|
||||
if (next == null) {
|
||||
await _complete();
|
||||
return;
|
||||
}
|
||||
await _moveTo(next);
|
||||
}
|
||||
|
||||
Future<void> _moveTo(ExecutionPosition position) async {
|
||||
_scoreController.clear();
|
||||
_reps = 0;
|
||||
_session = await widget.activeUseCases.updateProgress(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: position.programIndex,
|
||||
exerciseIndex: position.exerciseIndex,
|
||||
setIndex: position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.active);
|
||||
}
|
||||
|
||||
Future<void> _complete() async {
|
||||
_session = await widget.activeUseCases.complete(_session.metadata.id);
|
||||
await widget.closeUseCase.close(
|
||||
sessionId: _session.metadata.id,
|
||||
nameSnapshot: _plan.name,
|
||||
completed: true,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.finished);
|
||||
}
|
||||
}
|
||||
|
||||
final class SetMeasureInput extends StatelessWidget {
|
||||
const SetMeasureInput({
|
||||
required this.exercise,
|
||||
required this.reps,
|
||||
required this.scoreController,
|
||||
required this.onRepsChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ExecutionExercise exercise;
|
||||
final int reps;
|
||||
final TextEditingController scoreController;
|
||||
final ValueChanged<int> onRepsChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (exercise.timeEnabled)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('Temps'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
exercise.targetTimeSeconds == null
|
||||
? 'Chronométrer'
|
||||
: '${exercise.targetTimeSeconds} s',
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (exercise.repsEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Répétitions')),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
onRepsChanged((reps - 1).clamp(0, 999).toInt()),
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Text('$reps'),
|
||||
IconButton(
|
||||
onPressed: () => onRepsChanged(reps + 1),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (exercise.scoreEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: scoreController,
|
||||
decoration: InputDecoration(
|
||||
labelText: exercise.scoreUnit == null
|
||||
? 'Score'
|
||||
: 'Score (${exercise.scoreUnit})',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkoutExecutionPlan {
|
||||
const WorkoutExecutionPlan({required this.name, required this.programs});
|
||||
|
||||
factory WorkoutExecutionPlan.fromSession(ActiveWorkoutSession session) {
|
||||
final snapshot =
|
||||
jsonDecode(session.resolvedTemplateSnapshotJson)
|
||||
as Map<String, dynamic>;
|
||||
final overrides = snapshot['overrides'] as List<dynamic>? ?? const [];
|
||||
final programs = (snapshot['programs'] as List<dynamic>? ?? const []).map((
|
||||
rawProgram,
|
||||
) {
|
||||
final program = rawProgram as Map<String, dynamic>;
|
||||
final programSnapshot =
|
||||
jsonDecode(program['programSnapshotJson'] as String)
|
||||
as Map<String, dynamic>;
|
||||
return ExecutionProgram.fromSnapshot(
|
||||
id: program['id'] as String,
|
||||
name: program['programNameSnapshot'] as String,
|
||||
snapshot: programSnapshot,
|
||||
overrides: overrides,
|
||||
);
|
||||
}).toList();
|
||||
return WorkoutExecutionPlan(
|
||||
name: snapshot['name'] as String? ?? 'Séance',
|
||||
programs: programs,
|
||||
);
|
||||
}
|
||||
|
||||
final String name;
|
||||
final List<ExecutionProgram> programs;
|
||||
|
||||
ExecutionProgram programAt(ExecutionPosition position) {
|
||||
return programs[position.programIndex];
|
||||
}
|
||||
|
||||
ExecutionExercise exerciseAt(ExecutionPosition position) {
|
||||
return programAt(position).exercises[position.exerciseIndex];
|
||||
}
|
||||
|
||||
ExecutionPosition? nextPosition(ExecutionPosition position) {
|
||||
final exercise = exerciseAt(position);
|
||||
if (position.setIndex + 1 < exercise.setsCount) {
|
||||
return position.copyWith(setIndex: position.setIndex + 1);
|
||||
}
|
||||
final program = programAt(position);
|
||||
if (position.exerciseIndex + 1 < program.exercises.length) {
|
||||
return ExecutionPosition(
|
||||
programIndex: position.programIndex,
|
||||
exerciseIndex: position.exerciseIndex + 1,
|
||||
setIndex: 0,
|
||||
);
|
||||
}
|
||||
if (position.programIndex + 1 < programs.length) {
|
||||
return ExecutionPosition(
|
||||
programIndex: position.programIndex + 1,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool shouldShowRestAfter(ExecutionPosition position) {
|
||||
return nextPosition(position) != null &&
|
||||
exerciseAt(position).restSeconds > 0;
|
||||
}
|
||||
|
||||
String progressLabel(ExecutionPosition position) {
|
||||
final program = programAt(position);
|
||||
final exercise = exerciseAt(position);
|
||||
return 'Programme ${position.programIndex + 1}/${programs.length} · '
|
||||
'Exercice ${position.exerciseIndex + 1}/${program.exercises.length} · '
|
||||
'Série ${position.setIndex + 1}/${exercise.setsCount}';
|
||||
}
|
||||
}
|
||||
|
||||
final class ExecutionProgram {
|
||||
const ExecutionProgram({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.exercises,
|
||||
});
|
||||
|
||||
factory ExecutionProgram.fromSnapshot({
|
||||
required String id,
|
||||
required String name,
|
||||
required Map<String, dynamic> snapshot,
|
||||
required List<dynamic> overrides,
|
||||
}) {
|
||||
final exercises = (snapshot['exercises'] as List<dynamic>? ?? const []).map(
|
||||
(raw) {
|
||||
final exercise = raw as Map<String, dynamic>;
|
||||
Map<String, dynamic>? override;
|
||||
for (final rawOverride in overrides) {
|
||||
final item = rawOverride as Map<String, dynamic>;
|
||||
if (item['workoutTemplateProgramId'] == id &&
|
||||
item['snapshotProgramExerciseId'] == exercise['id']) {
|
||||
override = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ExecutionExercise.fromSnapshot(exercise, override);
|
||||
},
|
||||
).toList();
|
||||
return ExecutionProgram(id: id, name: name, exercises: exercises);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final List<ExecutionExercise> exercises;
|
||||
}
|
||||
|
||||
final class ExecutionExercise {
|
||||
const ExecutionExercise({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.setsCount,
|
||||
required this.timeEnabled,
|
||||
required this.repsEnabled,
|
||||
required this.scoreEnabled,
|
||||
required this.restSeconds,
|
||||
this.targetTimeSeconds,
|
||||
this.targetReps,
|
||||
this.targetScore,
|
||||
this.scoreUnit,
|
||||
});
|
||||
|
||||
factory ExecutionExercise.fromSnapshot(
|
||||
Map<String, dynamic> exercise,
|
||||
Map<String, dynamic>? override,
|
||||
) {
|
||||
return ExecutionExercise(
|
||||
id: exercise['id'] as String,
|
||||
name: exercise['exerciseNameSnapshot'] as String,
|
||||
setsCount:
|
||||
(override?['setsCountOverride'] as int?) ??
|
||||
(exercise['setsCount'] as int),
|
||||
timeEnabled: exercise['timeEnabled'] as bool,
|
||||
repsEnabled: exercise['repsEnabled'] as bool,
|
||||
scoreEnabled: exercise['scoreEnabled'] as bool,
|
||||
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||||
targetTimeSeconds:
|
||||
(override?['targetTimeSecondsOverride'] as int?) ??
|
||||
(exercise['targetTimeSeconds'] as int?),
|
||||
targetReps:
|
||||
(override?['targetRepsOverride'] as int?) ??
|
||||
(exercise['targetReps'] as int?),
|
||||
targetScore:
|
||||
(override?['targetScoreOverride'] as num?)?.toDouble() ??
|
||||
(exercise['targetScore'] as num?)?.toDouble(),
|
||||
scoreUnit: exercise['scoreUnitSnapshot'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final int setsCount;
|
||||
final bool timeEnabled;
|
||||
final bool repsEnabled;
|
||||
final bool scoreEnabled;
|
||||
final int restSeconds;
|
||||
final int? targetTimeSeconds;
|
||||
final int? targetReps;
|
||||
final double? targetScore;
|
||||
final String? scoreUnit;
|
||||
}
|
||||
|
||||
final class ExecutionPosition {
|
||||
const ExecutionPosition({
|
||||
required this.programIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.setIndex,
|
||||
});
|
||||
|
||||
final int programIndex;
|
||||
final int exerciseIndex;
|
||||
final int setIndex;
|
||||
|
||||
ExecutionPosition copyWith({int? setIndex}) {
|
||||
return ExecutionPosition(
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex ?? this.setIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _Header extends StatelessWidget {
|
||||
const _Header({required this.elapsedLabel, required this.progressLabel});
|
||||
|
||||
final String elapsedLabel;
|
||||
final String progressLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(elapsedLabel, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(progressLabel),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
final hours = duration.inHours;
|
||||
if (hours > 0) {
|
||||
return '$hours:$minutes:$seconds';
|
||||
}
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
@ -4,16 +4,21 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'workout_execution_screen.dart';
|
||||
|
||||
final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
const WorkoutTemplateListScreen({
|
||||
required this.workoutTemplateUseCases,
|
||||
required this.programUseCases,
|
||||
required this.activeUseCases,
|
||||
required this.closeUseCase,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ProgramUseCases programUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
|
||||
@override
|
||||
State<WorkoutTemplateListScreen> createState() =>
|
||||
@ -65,7 +70,10 @@ final class _WorkoutTemplateListScreenState
|
||||
trailing: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
TextButton(onPressed: () {}, child: const Text('Lancer')),
|
||||
TextButton(
|
||||
onPressed: () => _start(template),
|
||||
child: const Text('Lancer'),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
@ -99,6 +107,22 @@ final class _WorkoutTemplateListScreenState
|
||||
_reload();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _start(WorkoutTemplate template) async {
|
||||
final session = await widget.activeUseCases.startFromTemplate(
|
||||
template.metadata.id,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: widget.activeUseCases,
|
||||
closeUseCase: widget.closeUseCase,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkoutTemplateFormScreen extends StatefulWidget {
|
||||
|
||||
268
test/presentation/workout_execution_screen_test.dart
Normal file
268
test/presentation/workout_execution_screen_test.dart
Normal file
@ -0,0 +1,268 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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('le temps affiché est recalculé depuis les horodatages', (
|
||||
tester,
|
||||
) async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 30000,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(
|
||||
_FakeActiveSessionRepository(),
|
||||
clock,
|
||||
),
|
||||
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('00:45'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('les mesures cumulées respectent la hiérarchie visuelle', (
|
||||
tester,
|
||||
) async {
|
||||
final scoreController = TextEditingController();
|
||||
addTearDown(scoreController.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SetMeasureInput(
|
||||
exercise: const ExecutionExercise(
|
||||
id: 'exercise-1',
|
||||
name: 'Squat',
|
||||
setsCount: 3,
|
||||
timeEnabled: true,
|
||||
repsEnabled: true,
|
||||
scoreEnabled: true,
|
||||
restSeconds: 0,
|
||||
scoreUnit: 'kg',
|
||||
),
|
||||
reps: 0,
|
||||
scoreController: scoreController,
|
||||
onRepsChanged: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final timeY = tester.getTopLeft(find.text('Temps')).dy;
|
||||
final repsY = tester.getTopLeft(find.text('Répétitions')).dy;
|
||||
final scoreY = tester.getTopLeft(find.text('Score (kg)')).dy;
|
||||
|
||||
expect(timeY, lessThan(repsY));
|
||||
expect(repsY, lessThan(scoreY));
|
||||
});
|
||||
|
||||
testWidgets('aucun repos ne s’affiche après la toute dernière série', (
|
||||
tester,
|
||||
) async {
|
||||
final plan = WorkoutExecutionPlan.fromSession(
|
||||
ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||||
setsCount: 1,
|
||||
restSeconds: 60,
|
||||
),
|
||||
),
|
||||
);
|
||||
const position = ExecutionPosition(
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Text(
|
||||
plan.shouldShowRestAfter(position) ? 'Repos' : 'Pas de repos',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Pas de repos'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
ActiveWorkoutSessionUseCases _activeUseCases(
|
||||
_FakeActiveSessionRepository repository,
|
||||
_FakeClock clock,
|
||||
) {
|
||||
return ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: repository,
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
CloseWorkoutSessionUseCase _closeUseCase(
|
||||
_FakeActiveSessionRepository repository,
|
||||
_FakeClock clock,
|
||||
) {
|
||||
return CloseWorkoutSessionUseCase(
|
||||
sessionRepository: repository,
|
||||
historyRepository: _FakeWorkoutHistoryRepository(),
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
String _sessionSnapshot({int setsCount = 3, int restSeconds = 0}) {
|
||||
return jsonEncode({
|
||||
'name': 'Séance jambes',
|
||||
'programs': [
|
||||
{
|
||||
'id': 'template-program-1',
|
||||
'programNameSnapshot': 'Programme jambes',
|
||||
'programSnapshotJson': jsonEncode({
|
||||
'exercises': [
|
||||
{
|
||||
'id': 'exercise-1',
|
||||
'exerciseNameSnapshot': 'Squat',
|
||||
'setsCount': setsCount,
|
||||
'timeEnabled': true,
|
||||
'repsEnabled': true,
|
||||
'scoreEnabled': true,
|
||||
'targetTimeSeconds': 45,
|
||||
'targetReps': 10,
|
||||
'targetScore': 80,
|
||||
'scoreUnitSnapshot': 'kg',
|
||||
'restSecondsOverride': restSeconds,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
_FakeClock(this.value);
|
||||
|
||||
DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
@override
|
||||
String newId() {
|
||||
_next += 1;
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
ActiveWorkoutSession? session;
|
||||
final results = <ActiveSetResult>[];
|
||||
|
||||
@override
|
||||
Future<ActiveWorkoutSession?> findById(String id) async {
|
||||
return session?.metadata.id == id ? session : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveWorkoutSession?> findOpen() async => session;
|
||||
|
||||
@override
|
||||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
return results;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(ActiveWorkoutSession session) async {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveRestState(ActiveRestState restState) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||
results.add(result);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutTemplateRepository
|
||||
implements WorkoutTemplateRepository {
|
||||
@override
|
||||
Future<WorkoutTemplate?> findById(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||||
|
||||
@override
|
||||
Future<void> replaceComposition(
|
||||
WorkoutTemplate template,
|
||||
DateTime deletedAt,
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
}
|
||||
Reference in New Issue
Block a user