feat(ui): écran Exécution de séance
Ajoute l'écran d'exécution de séance (presentation/workout_execution_screen.dart), avec les ajustements application/domaine/repositories nécessaires et la navigation depuis l'accueil et l'écran séance-modèle. flutter 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:
@ -8,6 +8,8 @@ final class AppBootstrap {
|
|||||||
required this.mediaUseCases,
|
required this.mediaUseCases,
|
||||||
required this.programUseCases,
|
required this.programUseCases,
|
||||||
required this.workoutTemplateUseCases,
|
required this.workoutTemplateUseCases,
|
||||||
|
required this.activeWorkoutSessionUseCases,
|
||||||
|
required this.closeWorkoutSessionUseCase,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppDatabase database;
|
final AppDatabase database;
|
||||||
@ -15,6 +17,8 @@ final class AppBootstrap {
|
|||||||
final MediaUseCases mediaUseCases;
|
final MediaUseCases mediaUseCases;
|
||||||
final ProgramUseCases programUseCases;
|
final ProgramUseCases programUseCases;
|
||||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||||
|
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
||||||
|
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||||
|
|
||||||
static Future<AppBootstrap> create() async {
|
static Future<AppBootstrap> create() async {
|
||||||
final database = AppDatabase.open();
|
final database = AppDatabase.open();
|
||||||
@ -23,6 +27,8 @@ final class AppBootstrap {
|
|||||||
final mediaRepository = DriftMediaAssetRepository(database);
|
final mediaRepository = DriftMediaAssetRepository(database);
|
||||||
final programRepository = DriftProgramRepository(database);
|
final programRepository = DriftProgramRepository(database);
|
||||||
final templateRepository = DriftWorkoutTemplateRepository(database);
|
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||||
|
final activeSessionRepository = DriftActiveSessionRepository(database);
|
||||||
|
final historyRepository = DriftWorkoutHistoryRepository(database);
|
||||||
final ids = LocalIdGenerator();
|
final ids = LocalIdGenerator();
|
||||||
const clock = SystemClock();
|
const clock = SystemClock();
|
||||||
const originDeviceId = 'local-device';
|
const originDeviceId = 'local-device';
|
||||||
@ -57,6 +63,20 @@ final class AppBootstrap {
|
|||||||
ids: ids,
|
ids: ids,
|
||||||
originDeviceId: originDeviceId,
|
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 {
|
abstract interface class ActiveSessionRepository {
|
||||||
Future<ActiveWorkoutSession?> findById(String id);
|
Future<ActiveWorkoutSession?> findById(String id);
|
||||||
|
Future<ActiveWorkoutSession?> findOpen();
|
||||||
Future<void> save(ActiveWorkoutSession session);
|
Future<void> save(ActiveWorkoutSession session);
|
||||||
Future<void> saveSetResult(ActiveSetResult result);
|
Future<void> saveSetResult(ActiveSetResult result);
|
||||||
Future<void> saveRestState(ActiveRestState restState);
|
Future<void> saveRestState(ActiveRestState restState);
|
||||||
|
|||||||
@ -590,6 +590,8 @@ final class ActiveWorkoutSessionUseCases {
|
|||||||
final IdGenerator ids;
|
final IdGenerator ids;
|
||||||
final String originDeviceId;
|
final String originDeviceId;
|
||||||
|
|
||||||
|
Future<ActiveWorkoutSession?> findOpen() => sessionRepository.findOpen();
|
||||||
|
|
||||||
Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
|
Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
|
||||||
final template = await templateRepository.findById(templateId);
|
final template = await templateRepository.findById(templateId);
|
||||||
if (template == null) {
|
if (template == null) {
|
||||||
@ -659,6 +661,97 @@ final class ActiveWorkoutSessionUseCases {
|
|||||||
return result;
|
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({
|
Future<ActiveRestState> startRestAfterSet({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
required int afterProgramIndex,
|
required int afterProgramIndex,
|
||||||
|
|||||||
@ -362,6 +362,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return row == null ? null : _activeWorkoutSessionFromRow(row);
|
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
|
@override
|
||||||
Future<void> save(domain.ActiveWorkoutSession session) async {
|
Future<void> save(domain.ActiveWorkoutSession session) async {
|
||||||
await database
|
await database
|
||||||
|
|||||||
@ -1,21 +1,64 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../application/app_bootstrap.dart';
|
import '../application/app_bootstrap.dart';
|
||||||
|
import '../domain/domain.dart';
|
||||||
import 'exercise_library_screen.dart';
|
import 'exercise_library_screen.dart';
|
||||||
import 'program_screen.dart';
|
import 'program_screen.dart';
|
||||||
|
import 'workout_execution_screen.dart';
|
||||||
import 'workout_template_screen.dart';
|
import 'workout_template_screen.dart';
|
||||||
|
|
||||||
final class HomeScreen extends StatelessWidget {
|
final class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({required this.bootstrap, super.key});
|
const HomeScreen({required this.bootstrap, super.key});
|
||||||
|
|
||||||
final AppBootstrap bootstrap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('GameTime')),
|
appBar: AppBar(title: const Text('GameTime')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.fitness_center),
|
leading: const Icon(Icons.fitness_center),
|
||||||
title: const Text('Exercices'),
|
title: const Text('Exercices'),
|
||||||
@ -23,8 +66,8 @@ final class HomeScreen extends StatelessWidget {
|
|||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ExerciseLibraryScreen(
|
builder: (context) => ExerciseLibraryScreen(
|
||||||
exerciseUseCases: bootstrap.exerciseUseCases,
|
exerciseUseCases: widget.bootstrap.exerciseUseCases,
|
||||||
mediaUseCases: bootstrap.mediaUseCases,
|
mediaUseCases: widget.bootstrap.mediaUseCases,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -36,8 +79,8 @@ final class HomeScreen extends StatelessWidget {
|
|||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ProgramListScreen(
|
builder: (context) => ProgramListScreen(
|
||||||
programUseCases: bootstrap.programUseCases,
|
programUseCases: widget.bootstrap.programUseCases,
|
||||||
exerciseUseCases: bootstrap.exerciseUseCases,
|
exerciseUseCases: widget.bootstrap.exerciseUseCases,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -49,8 +92,11 @@ final class HomeScreen extends StatelessWidget {
|
|||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => WorkoutTemplateListScreen(
|
builder: (context) => WorkoutTemplateListScreen(
|
||||||
workoutTemplateUseCases: bootstrap.workoutTemplateUseCases,
|
workoutTemplateUseCases:
|
||||||
programUseCases: bootstrap.programUseCases,
|
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 'exercise_library_screen.dart';
|
||||||
export 'home_screen.dart';
|
export 'home_screen.dart';
|
||||||
export 'program_screen.dart';
|
export 'program_screen.dart';
|
||||||
|
export 'workout_execution_screen.dart';
|
||||||
export 'workout_template_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 '../application/application.dart';
|
||||||
import '../domain/domain.dart';
|
import '../domain/domain.dart';
|
||||||
|
import 'workout_execution_screen.dart';
|
||||||
|
|
||||||
final class WorkoutTemplateListScreen extends StatefulWidget {
|
final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||||
const WorkoutTemplateListScreen({
|
const WorkoutTemplateListScreen({
|
||||||
required this.workoutTemplateUseCases,
|
required this.workoutTemplateUseCases,
|
||||||
required this.programUseCases,
|
required this.programUseCases,
|
||||||
|
required this.activeUseCases,
|
||||||
|
required this.closeUseCase,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||||
final ProgramUseCases programUseCases;
|
final ProgramUseCases programUseCases;
|
||||||
|
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||||
|
final CloseWorkoutSessionUseCase closeUseCase;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<WorkoutTemplateListScreen> createState() =>
|
State<WorkoutTemplateListScreen> createState() =>
|
||||||
@ -65,7 +70,10 @@ final class _WorkoutTemplateListScreenState
|
|||||||
trailing: Wrap(
|
trailing: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
TextButton(onPressed: () {}, child: const Text('Lancer')),
|
TextButton(
|
||||||
|
onPressed: () => _start(template),
|
||||||
|
child: const Text('Lancer'),
|
||||||
|
),
|
||||||
const Icon(Icons.chevron_right),
|
const Icon(Icons.chevron_right),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -99,6 +107,22 @@ final class _WorkoutTemplateListScreenState
|
|||||||
_reload();
|
_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 {
|
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