Ajoute l'adapter API distant (infrastructure/remote/auth_api.dart, http_api_client.dart) et le stockage sécurisé du token d'auth (infrastructure/security/secure_storage_auth_token_store.dart), câblés dans app_bootstrap.dart. Étend le modèle Drift (migration schemaVersion 9→10) et la couche application (ports, use cases, entités) pour la session de compte. flutter pub get OK (ajout http, flutter_secure_storage), build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 109/109 tests verts, build APK debug validé. Premier ticket du chantier "Ajouter les features serveur au client" (#63). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
518 lines
14 KiB
Dart
518 lines
14 KiB
Dart
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)
|
||
: authUseCases = AuthUseCases(
|
||
tokenStore: _FakeAuthTokenStore(),
|
||
accountRepository: _FakeOnlineAccountRepository(),
|
||
remoteAuthApi: _FakeRemoteAuthApi(),
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||
ids: _FakeIds(),
|
||
),
|
||
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
|
||
sessionRepository: activeRepository,
|
||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
activeExerciseStepUseCases = ActiveExerciseStepUseCases(
|
||
sessionRepository: activeRepository,
|
||
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(),
|
||
programRepository: _FakeProgramRepository(),
|
||
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(),
|
||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||
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 AuthUseCases authUseCases;
|
||
|
||
@override
|
||
final ExerciseUseCases exerciseUseCases;
|
||
|
||
@override
|
||
final MediaUseCases mediaUseCases;
|
||
|
||
@override
|
||
final ProgramUseCases programUseCases;
|
||
|
||
@override
|
||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||
|
||
@override
|
||
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
||
|
||
@override
|
||
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
||
|
||
@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 _FakeAuthTokenStore implements AuthTokenStore {
|
||
@override
|
||
Future<void> clearToken() async {}
|
||
|
||
@override
|
||
Future<String?> readToken() async => null;
|
||
|
||
@override
|
||
Future<void> saveToken(String token, DateTime expiresAt) async {}
|
||
}
|
||
|
||
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
|
||
@override
|
||
Future<void> clearSession() async {}
|
||
|
||
@override
|
||
Future<UserAccountSession?> currentSession() async => null;
|
||
|
||
@override
|
||
Future<void> saveSession(UserAccountSession session) async {}
|
||
}
|
||
|
||
final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
||
@override
|
||
Future<RemoteAuthResult> login({
|
||
required String email,
|
||
required String password,
|
||
}) async {
|
||
throw const RemoteAuthException(RemoteAuthFailure.network);
|
||
}
|
||
|
||
@override
|
||
Future<void> logout(String token) async {}
|
||
|
||
@override
|
||
Future<RemoteAuthResult> register({
|
||
required String email,
|
||
required String password,
|
||
String? displayName,
|
||
}) async {
|
||
throw const RemoteAuthException(RemoteAuthFailure.network);
|
||
}
|
||
}
|
||
|
||
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<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||
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<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||
String sessionId,
|
||
) async {
|
||
return const [];
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||
String sessionId,
|
||
) async {
|
||
return const [];
|
||
}
|
||
|
||
@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> saveExerciseStepProgressState(
|
||
ActiveExerciseStepProgressState state,
|
||
) async {}
|
||
|
||
@override
|
||
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
|
||
|
||
@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 {}
|
||
|
||
@override
|
||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||
}
|