merge(main): bandeau de reprise de séance en cours (ticket #32)

Fusionne feature/#32-bandeau-reprise — analyze propre, 45/45 tests
verts, build APK debug validé. Ticket laissé en statut QA :
validation manuelle sur APK par l'utilisateur avant clôture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:52:50 +02:00
7 changed files with 482 additions and 14 deletions

View File

@ -1,6 +1,9 @@
--- ---
issueRef: "#32" issueRef: "#32"
version: 4 version: 6
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784329025027 updatedAt: 1784361147125
--- ---
Correctif : l'écran d'accueil recharge maintenant l'état de la séance active à chaque retour sur l'accueil (via RouteObserver/RouteAware.didPopNext), pas seulement au lancement de l'app. Fonctionne après "Quitter et sauvegarder" (bandeau apparaît) et après "Abandonner la séance" (bandeau reste absent). Vérifié : analyze propre, 45/45 tests verts, build APK réussi.
À tester sur le téléphone : lancer une séance, la mettre en pause et "Quitter et sauvegarder" sans redémarrer l'app — le bandeau "Séance en cours" doit apparaître immédiatement sur l'accueil.

View File

@ -2,15 +2,15 @@
id: "a3fe5103-8b5c-4cce-80af-f1dd08b76794" id: "a3fe5103-8b5c-4cce-80af-f1dd08b76794"
number: 32 number: 32
title: "[Bug] Reprendre séance en cours pas toujours affiché" title: "[Bug] Reprendre séance en cours pas toujours affiché"
status: "open" status: "qa"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784328903547 createdAt: 1784328903547
updatedAt: 1784329025027 updatedAt: 1784361147125
version: 4 version: 6
--- ---
Si je quitte une seance en cours, pour voir le bandeau de reprise de seance s'afficher je dois quitter totalement l'application et la relancer. J'aimerais qu'il y soit automatiquement quand une séance et en cours et que je ne suis pas dans ma séance Si je quitte une seance en cours, pour voir le bandeau de reprise de seance s'afficher je dois quitter totalement l'application et la relancer. J'aimerais qu'il y soit automatiquement quand une séance et en cours et que je ne suis pas dans ma séance

View File

@ -359,13 +359,13 @@
"issueRef": "#32", "issueRef": "#32",
"path": "32", "path": "32",
"title": "[Bug] Reprendre séance en cours pas toujours affiché", "title": "[Bug] Reprendre séance en cours pas toujours affiché",
"status": "open", "status": "qa",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784329025027 "updatedAt": 1784361147125
}, },
{ {
"issueRef": "#33", "issueRef": "#33",

View File

@ -1,7 +1,17 @@
import '../infrastructure/local/local.dart'; import '../infrastructure/local/local.dart';
import 'application.dart'; import 'application.dart';
final class AppBootstrap { abstract interface class AppDependencies {
ExerciseUseCases get exerciseUseCases;
MediaUseCases get mediaUseCases;
ProgramUseCases get programUseCases;
WorkoutTemplateUseCases get workoutTemplateUseCases;
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases;
}
final class AppBootstrap implements AppDependencies {
AppBootstrap._({ AppBootstrap._({
required this.database, required this.database,
required this.exerciseUseCases, required this.exerciseUseCases,

View File

@ -23,6 +23,7 @@ final class _GameTimeAppState extends State<GameTimeApp> {
theme: courtBlazerLightTheme(), theme: courtBlazerLightTheme(),
darkTheme: courtBlazerDarkTheme(), darkTheme: courtBlazerDarkTheme(),
themeMode: _themeMode, themeMode: _themeMode,
navigatorObservers: [homeRouteObserver],
home: HomeScreen( home: HomeScreen(
bootstrap: widget.bootstrap, bootstrap: widget.bootstrap,
themeMode: _themeMode, themeMode: _themeMode,

View File

@ -9,6 +9,8 @@ import 'theme.dart';
import 'workout_execution_screen.dart'; import 'workout_execution_screen.dart';
import 'workout_template_screen.dart'; import 'workout_template_screen.dart';
final homeRouteObserver = RouteObserver<ModalRoute<void>>();
final class HomeScreen extends StatefulWidget { final class HomeScreen extends StatefulWidget {
const HomeScreen({ const HomeScreen({
required this.bootstrap, required this.bootstrap,
@ -17,7 +19,7 @@ final class HomeScreen extends StatefulWidget {
super.key, super.key,
}); });
final AppBootstrap bootstrap; final AppDependencies bootstrap;
final ThemeMode themeMode; final ThemeMode themeMode;
final ValueChanged<ThemeMode>? onThemeModeChanged; final ValueChanged<ThemeMode>? onThemeModeChanged;
@ -25,13 +27,40 @@ final class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
final class _HomeScreenState extends State<HomeScreen> { final class _HomeScreenState extends State<HomeScreen> with RouteAware {
late Future<ActiveWorkoutSession?> _openSession; late Future<ActiveWorkoutSession?> _openSession;
ModalRoute<void>? _route;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen(); _openSession = _findOpenSession();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of<void>(context);
if (route == null || identical(route, _route)) {
return;
}
final previousRoute = _route;
if (previousRoute != null) {
homeRouteObserver.unsubscribe(this);
}
_route = route;
homeRouteObserver.subscribe(this, route);
}
@override
void dispose() {
homeRouteObserver.unsubscribe(this);
super.dispose();
}
@override
void didPopNext() {
_reloadOpenSession();
} }
@override @override
@ -163,8 +192,16 @@ final class _HomeScreenState extends State<HomeScreen> {
), ),
); );
if (!mounted) return; if (!mounted) return;
_reloadOpenSession();
}
Future<ActiveWorkoutSession?> _findOpenSession() {
return widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
}
void _reloadOpenSession() {
setState(() { setState(() {
_openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen(); _openSession = _findOpenSession();
}); });
} }
} }

View File

@ -0,0 +1,417 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/application/app_bootstrap.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/home_screen.dart';
void main() {
testWidgets('le bandeau de reprise apparaît au retour sur laccueil', (
tester,
) async {
final navigatorKey = GlobalKey<NavigatorState>();
final activeRepository = _FakeActiveSessionRepository();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
navigatorObservers: [homeRouteObserver],
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Séance en cours'), findsNothing);
unawaited(
navigatorKey.currentState!.push<void>(
MaterialPageRoute(
builder: (context) => const Scaffold(body: Text('Séance active')),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
activeRepository.session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 12),
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
elapsedActiveMs: 300000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
navigatorKey.currentState!.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
await tester.pump();
expect(
find.text('Séance en cours — Reprendre · Squat, série 2/3'),
findsOneWidget,
);
});
testWidgets('le bandeau de reprise disparaît au retour après abandon', (
tester,
) async {
final navigatorKey = GlobalKey<NavigatorState>();
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 12),
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
elapsedActiveMs: 300000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
navigatorObservers: [homeRouteObserver],
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Séance en cours'), findsOneWidget);
unawaited(
navigatorKey.currentState!.push<void>(
MaterialPageRoute(
builder: (context) => const Scaffold(body: Text('Séance active')),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
activeRepository.session = null;
navigatorKey.currentState!.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
await tester.pump();
expect(find.textContaining('Séance en cours'), findsNothing);
});
}
final class _FakeBootstrap implements AppDependencies {
_FakeBootstrap(_FakeActiveSessionRepository activeRepository)
: activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: _FakeWorkoutHistoryRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
exerciseUseCases = ExerciseUseCases(
repository: _FakeExerciseRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
mediaUseCases = MediaUseCases(
mediaRepository: _FakeMediaAssetRepository(),
exerciseRepository: _FakeExerciseRepository(),
storage: _FakeMediaStorage(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases = ProgramUseCases(
programRepository: _FakeProgramRepository(),
exerciseRepository: _FakeExerciseRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
workoutTemplateUseCases = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
workoutHistoryUseCases = WorkoutHistoryUseCases(
repository: _FakeWorkoutHistoryRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
);
@override
final ExerciseUseCases exerciseUseCases;
@override
final MediaUseCases mediaUseCases;
@override
final ProgramUseCases programUseCases;
@override
final WorkoutTemplateUseCases workoutTemplateUseCases;
@override
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override
final WorkoutHistoryUseCases workoutHistoryUseCases;
}
String _sessionSnapshot() {
return jsonEncode({
'name': 'Séance jambes',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme jambes',
'programSnapshotJson': jsonEncode({
'exercises': [
{
'id': 'exercise-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': 3,
'timeEnabled': true,
'repsEnabled': false,
'scoreEnabled': false,
'restSecondsOverride': 0,
},
],
}),
},
],
'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 {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
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<ActiveRestState?> findRestStateById(String id) async => null;
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates;
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates;
}
@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 {
restStates.add(restState);
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates.add(state);
}
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
}
}
final class _FakeExerciseRepository implements ExerciseRepository {
@override
Future<Exercise?> findById(String id) async => null;
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override
Future<List<Exercise>> listActive() async => const [];
@override
Future<void> save(Exercise exercise) async {}
}
final class _FakeMediaAssetRepository implements MediaAssetRepository {
@override
Future<MediaAsset?> findById(String id) async => null;
@override
Future<List<MediaAsset>> listActive() async => const [];
@override
Future<void> save(MediaAsset mediaAsset) async {}
}
final class _FakeMediaStorage implements LocalMediaStorage {
@override
Future<void> deleteByLocalUri(String localUri) async {}
@override
Future<StoredMediaFile> importFile({
required String sourcePath,
required MediaKind kind,
required String stableFileName,
}) async {
return StoredMediaFile(
localUri: 'file:///$stableFileName',
mimeType: 'image/png',
sizeBytes: 0,
);
}
@override
Future<Set<String>> listManagedLocalUris() async => const {};
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}
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> delete(String id, DateTime deletedAt) async {}
@override
Future<WorkoutHistory?> findById(String id) async => null;
@override
Future<List<WorkoutHistory>> listActive() async => const [];
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
}