Ajoute l'écran de partage sortant (presentation/share_screen.dart) et la boîte de réception des partages (presentation/share_inbox_screen.dart), avec les points d'entrée sur profile_screen.dart, home_screen.dart, program_screen.dart et workout_template_screen.dart. flutter analyze propre (mêmes infos préexistantes), 132/132 tests verts, build APK debug validé. Dernier ticket de développement du chantier "Ajouter les features serveur au client" (#63) — il ne reste que la QA finale (#70). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
656 lines
18 KiB
Dart
656 lines
18 KiB
Dart
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(
|
||
'la vue programme de séance ne permet pas de changer les mesures actives',
|
||
(tester) async {
|
||
final program = WorkoutTemplateProgramDraft(
|
||
clientKey: 'program-1',
|
||
programNameSnapshot: 'Jambes',
|
||
defaultRestSecondsSnapshot: 60,
|
||
programSnapshotJson: '{}',
|
||
exercises: [
|
||
WorkoutTemplateExerciseDraft(
|
||
snapshotExerciseId: 'exercise-1',
|
||
exerciseNameSnapshot: 'Squat',
|
||
setsCount: 3,
|
||
timeEnabled: true,
|
||
repsEnabled: true,
|
||
scoreEnabled: false,
|
||
targetTimeSeconds: 45,
|
||
targetReps: 10,
|
||
),
|
||
],
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)),
|
||
);
|
||
|
||
expect(find.byType(CheckboxListTile), findsNothing);
|
||
expect(find.text('Mesures à suivre'), findsNothing);
|
||
|
||
await tester.enterText(find.byType(TextFormField).first, '5');
|
||
await tester.pump();
|
||
|
||
expect(program.exercises.single.setsCountOverride, 5);
|
||
expect(find.text('5 séries · Temps + Répétitions'), findsOneWidget);
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'lancer une séance alors qu’une autre est en cours affiche un message',
|
||
(tester) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final templateRepository = _FakeWorkoutTemplateRepository([
|
||
_workoutTemplate(),
|
||
]);
|
||
final activeRepository = _FakeActiveSessionRepository()
|
||
..session = _activeSession();
|
||
final programRepository = _FakeProgramRepository();
|
||
final historyRepository = _FakeWorkoutHistoryRepository();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutTemplateListScreen(
|
||
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||
templateRepository: templateRepository,
|
||
programRepository: programRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
programUseCases: ProgramUseCases(
|
||
programRepository: programRepository,
|
||
exerciseRepository: _FakeExerciseRepository(),
|
||
templateRepository: templateRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
activeUseCases: ActiveWorkoutSessionUseCases(
|
||
sessionRepository: activeRepository,
|
||
templateRepository: templateRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
closeUseCase: CloseWorkoutSessionUseCase(
|
||
sessionRepository: activeRepository,
|
||
historyRepository: historyRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
historyUseCases: WorkoutHistoryUseCases(
|
||
repository: historyRepository,
|
||
clock: clock,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Lancer'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(
|
||
find.textContaining('Une séance est déjà en cours.'),
|
||
findsOneWidget,
|
||
);
|
||
expect(activeRepository.saveCount, 0);
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'supprimer une séance demande confirmation puis la retire de la liste',
|
||
(tester) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final templateRepository = _FakeWorkoutTemplateRepository([
|
||
_workoutTemplate(),
|
||
]);
|
||
final activeRepository = _FakeActiveSessionRepository();
|
||
final programRepository = _FakeProgramRepository();
|
||
final historyRepository = _FakeWorkoutHistoryRepository();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutTemplateListScreen(
|
||
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||
templateRepository: templateRepository,
|
||
programRepository: programRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
programUseCases: ProgramUseCases(
|
||
programRepository: programRepository,
|
||
exerciseRepository: _FakeExerciseRepository(),
|
||
templateRepository: templateRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
activeUseCases: ActiveWorkoutSessionUseCases(
|
||
sessionRepository: activeRepository,
|
||
templateRepository: templateRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
closeUseCase: CloseWorkoutSessionUseCase(
|
||
sessionRepository: activeRepository,
|
||
historyRepository: historyRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
historyUseCases: WorkoutHistoryUseCases(
|
||
repository: historyRepository,
|
||
clock: clock,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.byTooltip('Supprimer la séance'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Supprimer Séance jambes ?'), findsOneWidget);
|
||
expect(find.text('Cette action est irréversible.'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Annuler'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Séance jambes'), findsOneWidget);
|
||
expect(templateRepository.saved, isEmpty);
|
||
|
||
await tester.tap(find.byTooltip('Supprimer la séance'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Supprimer'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Séance jambes'), findsNothing);
|
||
expect(templateRepository.saved.single.metadata.deletedAt, isNotNull);
|
||
},
|
||
);
|
||
|
||
testWidgets('partager une séance ouvre le formulaire de partage', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final templateRepository = _FakeWorkoutTemplateRepository([
|
||
_workoutTemplate(),
|
||
]);
|
||
final programRepository = _FakeProgramRepository();
|
||
final remoteShareApi = _FakeRemoteShareApi();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutTemplateFormScreen(
|
||
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||
templateRepository: templateRepository,
|
||
programRepository: programRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
programUseCases: ProgramUseCases(
|
||
programRepository: programRepository,
|
||
exerciseRepository: _FakeExerciseRepository(),
|
||
templateRepository: templateRepository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
shareUseCases: _shareUseCases(
|
||
templateRepository: templateRepository,
|
||
programRepository: programRepository,
|
||
remoteShareApi: remoteShareApi,
|
||
),
|
||
template: templateRepository.templates.single,
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Partager'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Partager la séance'), findsOneWidget);
|
||
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, 'Emails destinataires'),
|
||
'coach@example.com',
|
||
);
|
||
await tester.tap(find.text('Envoyer le partage'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(remoteShareApi.lastResourceType, ShareResourceType.workoutTemplate);
|
||
expect(remoteShareApi.lastRecipientEmails, ['coach@example.com']);
|
||
});
|
||
}
|
||
|
||
WorkoutTemplate _workoutTemplate() {
|
||
return WorkoutTemplate(
|
||
metadata: _metadata('template-1'),
|
||
name: 'Séance jambes',
|
||
programs: [
|
||
WorkoutTemplateProgram(
|
||
metadata: _metadata('template-program-1'),
|
||
workoutTemplateId: 'template-1',
|
||
position: 0,
|
||
programNameSnapshot: 'Programme jambes',
|
||
defaultRestSecondsSnapshot: 60,
|
||
programSnapshotJson: _programSnapshotJson(),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
ActiveWorkoutSession _activeSession() {
|
||
return ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-active',
|
||
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: jsonEncode({
|
||
'name': 'Séance active',
|
||
'programs': [
|
||
{
|
||
'id': 'template-program-1',
|
||
'programNameSnapshot': 'Programme jambes',
|
||
'programSnapshotJson': _programSnapshotJson(),
|
||
},
|
||
],
|
||
'overrides': const [],
|
||
}),
|
||
);
|
||
}
|
||
|
||
String _programSnapshotJson() {
|
||
return jsonEncode({
|
||
'exercises': [
|
||
{
|
||
'id': 'exercise-1',
|
||
'exerciseNameSnapshot': 'Squat',
|
||
'setsCount': 3,
|
||
'timeEnabled': true,
|
||
'repsEnabled': false,
|
||
'scoreEnabled': false,
|
||
'restSecondsOverride': 60,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
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 _FakeWorkoutTemplateRepository
|
||
implements WorkoutTemplateRepository {
|
||
_FakeWorkoutTemplateRepository(this.templates);
|
||
|
||
final List<WorkoutTemplate> templates;
|
||
final saved = <WorkoutTemplate>[];
|
||
|
||
@override
|
||
Future<WorkoutTemplate?> findById(String id) async {
|
||
return templates.cast<WorkoutTemplate?>().firstWhere(
|
||
(template) => template?.metadata.id == id,
|
||
orElse: () => null,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<List<WorkoutTemplate>> listActive() async {
|
||
return templates
|
||
.where((template) => template.metadata.deletedAt == null)
|
||
.toList();
|
||
}
|
||
|
||
@override
|
||
Future<void> replaceComposition(
|
||
WorkoutTemplate template,
|
||
DateTime deletedAt,
|
||
) async {
|
||
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
|
||
templates.add(template);
|
||
}
|
||
|
||
@override
|
||
Future<void> save(WorkoutTemplate template) async {
|
||
saved.add(template);
|
||
templates.removeWhere((item) => item.metadata.id == template.metadata.id);
|
||
templates.add(template);
|
||
}
|
||
|
||
@override
|
||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||
|
||
@override
|
||
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||
}
|
||
|
||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||
ActiveWorkoutSession? session;
|
||
var saveCount = 0;
|
||
|
||
@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 const [];
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
||
String sessionId,
|
||
) async {
|
||
return const [];
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||
return const [];
|
||
}
|
||
|
||
@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 {
|
||
saveCount += 1;
|
||
this.session = session;
|
||
}
|
||
|
||
@override
|
||
Future<void> saveRestState(ActiveRestState restState) async {}
|
||
|
||
@override
|
||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
||
|
||
@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 {}
|
||
}
|
||
|
||
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 _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 _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 {}
|
||
}
|
||
|
||
ShareUseCases _shareUseCases({
|
||
required _FakeWorkoutTemplateRepository templateRepository,
|
||
required _FakeProgramRepository programRepository,
|
||
_FakeRemoteShareApi? remoteShareApi,
|
||
}) {
|
||
return ShareUseCases(
|
||
tokenStore: _FakeAuthTokenStore('token-1'),
|
||
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
|
||
inboxRepository: _FakeShareInboxRepository(),
|
||
pendingActionRepository: _FakePendingShareActionRepository(),
|
||
localChanges: _FakeLocalSyncChangeRepository(),
|
||
programRepository: programRepository,
|
||
templateRepository: templateRepository,
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||
ids: _FakeIds(),
|
||
);
|
||
}
|
||
|
||
final class _FakeAuthTokenStore implements AuthTokenStore {
|
||
_FakeAuthTokenStore([this.token]);
|
||
|
||
String? token;
|
||
|
||
@override
|
||
Future<void> clearToken() async {
|
||
token = null;
|
||
}
|
||
|
||
@override
|
||
Future<String?> readToken() async => token;
|
||
|
||
@override
|
||
Future<void> saveToken(String token, DateTime expiresAt) async {
|
||
this.token = token;
|
||
}
|
||
}
|
||
|
||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||
ShareResourceType? lastResourceType;
|
||
List<String> lastRecipientEmails = const [];
|
||
|
||
@override
|
||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||
return RemoteSyncedItem(
|
||
resourceType: SyncResourceType.workoutTemplate,
|
||
clientId: 'template-remote',
|
||
serverId: 'template-server',
|
||
schemaVersion: 1,
|
||
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||
deletedAt: null,
|
||
payload: const {'name': 'Séance partagée'},
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> declineShare(String shareId, String token) async {}
|
||
|
||
@override
|
||
Future<List<ShareInboxItem>> fetchInbox(String token) async => const [];
|
||
|
||
@override
|
||
Future<void> revokeShare(String shareId, String token) async {}
|
||
|
||
@override
|
||
Future<RemoteShareSendResult> sendShare({
|
||
required ShareResourceType resourceType,
|
||
required Map<String, Object?> payload,
|
||
required List<String> recipientEmails,
|
||
required String token,
|
||
}) async {
|
||
lastResourceType = resourceType;
|
||
lastRecipientEmails = recipientEmails;
|
||
return const RemoteShareSendResult(
|
||
shareId: 'share-1',
|
||
recipientUserIds: ['recipient-1'],
|
||
unresolvedEmails: [],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||
@override
|
||
Future<ShareInboxItem?> findByShareId(String shareId) async => null;
|
||
|
||
@override
|
||
Future<List<ShareInboxItem>> listAll() async => const [];
|
||
|
||
@override
|
||
Future<void> markStatus(
|
||
String shareId,
|
||
ShareInboxStatus status,
|
||
DateTime respondedAt,
|
||
) async {}
|
||
|
||
@override
|
||
Future<void> upsert(ShareInboxItem item) async {}
|
||
|
||
@override
|
||
Future<void> upsertAll(List<ShareInboxItem> items) async {}
|
||
}
|
||
|
||
final class _FakePendingShareActionRepository
|
||
implements PendingShareActionRepository {
|
||
@override
|
||
Future<void> add(PendingShareAction action) async {}
|
||
|
||
@override
|
||
Future<List<PendingShareAction>> listPending() async => const [];
|
||
|
||
@override
|
||
Future<void> markFailed(String id, DateTime attemptedAt) async {}
|
||
|
||
@override
|
||
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
|
||
}
|
||
|
||
final class _FakeLocalSyncChangeRepository
|
||
implements LocalSyncChangeRepository {
|
||
@override
|
||
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
|
||
|
||
@override
|
||
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
|
||
|
||
@override
|
||
Future<void> markChangesSynced(
|
||
List<String> changeLogIds,
|
||
DateTime syncedAt,
|
||
) async {}
|
||
}
|