diff --git a/.ideai/tickets/32/carnet.md b/.ideai/tickets/32/carnet.md index 4f0e5ed..ff86ae2 100644 --- a/.ideai/tickets/32/carnet.md +++ b/.ideai/tickets/32/carnet.md @@ -1,6 +1,9 @@ --- issueRef: "#32" -version: 4 -updatedBy: {"kind":"user"} -updatedAt: 1784329025027 +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +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. \ No newline at end of file diff --git a/.ideai/tickets/32/issue.md b/.ideai/tickets/32/issue.md index b17fca5..9409e25 100644 --- a/.ideai/tickets/32/issue.md +++ b/.ideai/tickets/32/issue.md @@ -2,15 +2,15 @@ id: "a3fe5103-8b5c-4cce-80af-f1dd08b76794" number: 32 title: "[Bug] Reprendre séance en cours pas toujours affiché" -status: "open" +status: "qa" priority: "medium" sprint: "abc4f969-b169-45f7-988c-daeeab762201" links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784328903547 -updatedAt: 1784329025027 -version: 4 +updatedAt: 1784361147125 +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 \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 0da3ce1..a4db73c 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -359,13 +359,13 @@ "issueRef": "#32", "path": "32", "title": "[Bug] Reprendre séance en cours pas toujours affiché", - "status": "open", + "status": "qa", "priority": "medium", "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784329025027 + "updatedAt": 1784361147125 }, { "issueRef": "#33", diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 9044530..0b38037 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -1,7 +1,17 @@ import '../infrastructure/local/local.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._({ required this.database, required this.exerciseUseCases, diff --git a/lib/presentation/game_time_app.dart b/lib/presentation/game_time_app.dart index d3ff767..3441c28 100644 --- a/lib/presentation/game_time_app.dart +++ b/lib/presentation/game_time_app.dart @@ -23,6 +23,7 @@ final class _GameTimeAppState extends State { theme: courtBlazerLightTheme(), darkTheme: courtBlazerDarkTheme(), themeMode: _themeMode, + navigatorObservers: [homeRouteObserver], home: HomeScreen( bootstrap: widget.bootstrap, themeMode: _themeMode, diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index a5b9fb5..e9426ea 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -9,6 +9,8 @@ import 'theme.dart'; import 'workout_execution_screen.dart'; import 'workout_template_screen.dart'; +final homeRouteObserver = RouteObserver>(); + final class HomeScreen extends StatefulWidget { const HomeScreen({ required this.bootstrap, @@ -17,7 +19,7 @@ final class HomeScreen extends StatefulWidget { super.key, }); - final AppBootstrap bootstrap; + final AppDependencies bootstrap; final ThemeMode themeMode; final ValueChanged? onThemeModeChanged; @@ -25,13 +27,40 @@ final class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -final class _HomeScreenState extends State { +final class _HomeScreenState extends State with RouteAware { late Future _openSession; + ModalRoute? _route; @override void initState() { super.initState(); - _openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen(); + _openSession = _findOpenSession(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(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 @@ -163,8 +192,16 @@ final class _HomeScreenState extends State { ), ); if (!mounted) return; + _reloadOpenSession(); + } + + Future _findOpenSession() { + return widget.bootstrap.activeWorkoutSessionUseCases.findOpen(); + } + + void _reloadOpenSession() { setState(() { - _openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen(); + _openSession = _findOpenSession(); }); } } diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart new file mode 100644 index 0000000..e86a799 --- /dev/null +++ b/test/presentation/home_screen_test.dart @@ -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 l’accueil', ( + tester, + ) async { + final navigatorKey = GlobalKey(); + 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( + 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(); + 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( + 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 = []; + final scoreStopwatchStates = []; + final results = []; + + @override + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } + + @override + Future findOpen() async => session; + + @override + Future findRestStateById(String id) async => null; + + @override + Future findScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return null; + } + + @override + Future> listRestStates(String sessionId) async { + return restStates; + } + + @override + Future> listScoreStopwatchStates( + String sessionId, + ) async { + return scoreStopwatchStates; + } + + @override + Future> listSetResults(String sessionId) async { + return results; + } + + @override + Future save(ActiveWorkoutSession session) async { + this.session = session; + } + + @override + Future saveRestState(ActiveRestState restState) async { + restStates.add(restState); + } + + @override + Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { + scoreStopwatchStates.add(state); + } + + @override + Future deleteScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async {} + + @override + Future saveSetResult(ActiveSetResult result) async { + results.add(result); + } +} + +final class _FakeExerciseRepository implements ExerciseRepository { + @override + Future findById(String id) async => null; + + @override + Future isReferencedByProgram(String id) async => false; + + @override + Future> listActive() async => const []; + + @override + Future save(Exercise exercise) async {} +} + +final class _FakeMediaAssetRepository implements MediaAssetRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future save(MediaAsset mediaAsset) async {} +} + +final class _FakeMediaStorage implements LocalMediaStorage { + @override + Future deleteByLocalUri(String localUri) async {} + + @override + Future importFile({ + required String sourcePath, + required MediaKind kind, + required String stableFileName, + }) async { + return StoredMediaFile( + localUri: 'file:///$stableFileName', + mimeType: 'image/png', + sizeBytes: 0, + ); + } + + @override + Future> listManagedLocalUris() async => const {}; +} + +final class _FakeProgramRepository implements ProgramRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +} + +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} + +final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { + @override + Future delete(String id, DateTime deletedAt) async {} + + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future save(WorkoutHistory history) async {} + + @override + Future saveSetResult(WorkoutHistorySetResult result) async {} +}