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:
@ -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.
|
||||
@ -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
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -23,6 +23,7 @@ final class _GameTimeAppState extends State<GameTimeApp> {
|
||||
theme: courtBlazerLightTheme(),
|
||||
darkTheme: courtBlazerDarkTheme(),
|
||||
themeMode: _themeMode,
|
||||
navigatorObservers: [homeRouteObserver],
|
||||
home: HomeScreen(
|
||||
bootstrap: widget.bootstrap,
|
||||
themeMode: _themeMode,
|
||||
|
||||
@ -9,6 +9,8 @@ import 'theme.dart';
|
||||
import 'workout_execution_screen.dart';
|
||||
import 'workout_template_screen.dart';
|
||||
|
||||
final homeRouteObserver = RouteObserver<ModalRoute<void>>();
|
||||
|
||||
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<ThemeMode>? onThemeModeChanged;
|
||||
|
||||
@ -25,13 +27,40 @@ final class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
final class _HomeScreenState extends State<HomeScreen> {
|
||||
final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
late Future<ActiveWorkoutSession?> _openSession;
|
||||
ModalRoute<void>? _route;
|
||||
|
||||
@override
|
||||
void 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
|
||||
@ -163,8 +192,16 @@ final class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
_reloadOpenSession();
|
||||
}
|
||||
|
||||
Future<ActiveWorkoutSession?> _findOpenSession() {
|
||||
return widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
|
||||
}
|
||||
|
||||
void _reloadOpenSession() {
|
||||
setState(() {
|
||||
_openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
|
||||
_openSession = _findOpenSession();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
417
test/presentation/home_screen_test.dart
Normal file
417
test/presentation/home_screen_test.dart
Normal 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 l’accueil', (
|
||||
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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user