Files
GameTime/test/presentation/profile_screen_test.dart
Blomios 65d43b9768 feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 05:56:12 +02:00

1121 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:typed_data';
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/profile_screen.dart';
import 'package:share_plus/share_plus.dart';
void main() {
testWidgets('le profil déconnecté invite sans bloquer le reste', (
tester,
) async {
final harness = _AuthHarness();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Compte GameTime'), findsOneWidget);
expect(find.text('Créer un compte'), findsOneWidget);
expect(find.text('Se connecter'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('la connexion invalide affiche une erreur inline', (
tester,
) async {
final harness = _AuthHarness()
..remote.loginFailure = RemoteAuthFailure.invalidCredentials;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Tu peux revenir à GameTime.'), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, 'Email'),
'a@b.fr',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Mot de passe'),
'password1',
);
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('une erreur serveur affiche le message dédié', (tester) async {
final harness = _AuthHarness()
..remote.loginFailure = RemoteAuthFailure.server;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Email'),
'a@b.fr',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Mot de passe'),
'password1',
);
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(
find.text('Serveur indisponible pour le moment. Réessaie plus tard.'),
findsOneWidget,
);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('la connexion réussie affiche le profil connecté', (
tester,
) async {
final harness = _AuthHarness();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Email'),
'alex@example.com',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Mot de passe'),
'password1',
);
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('alex@example.com'), findsOneWidget);
expect(find.text('Synchronisation'), findsOneWidget);
expect(find.text('Partages reçus'), findsOneWidget);
expect(harness.syncRemote.pullCalls, 1);
});
testWidgets('la déconnexion repasse au profil déconnecté', (tester) async {
final harness = _AuthHarness(
initialSession: UserAccountSession(
id: 'account-1',
serverUserId: 'server-user-1',
email: 'alex@example.com',
displayName: 'Alex',
isLoggedIn: true,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
),
initialToken: 'token-1',
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Alex'), findsOneWidget);
await tester.tap(find.text('Se déconnecter'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Se déconnecter'));
await tester.pumpAndSettle();
expect(harness.tokenStore.token, isNull);
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
expect(find.text('Compte GameTime'), findsOneWidget);
});
testWidgets(
'le bouton de synchronisation lance une synchronisation manuelle',
(tester) async {
final harness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Synchroniser maintenant'), findsOneWidget);
await tester.tap(find.text('Synchroniser maintenant'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(harness.syncRemote.pullCalls, 1);
},
);
testWidgets('un souci de synchronisation reste neutre dans le profil', (
tester,
) async {
final harness = _AuthHarness.connected()..syncRemote.throwOnPull = true;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Synchroniser maintenant'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(harness.syncMetadata.metadata.status, OnlineSyncStatus.failure);
expect(find.text('Synchronisation en attente'), findsOneWidget);
expect(find.textContaining('Erreur'), findsNothing);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('le statut se met à jour après une synchronisation réussie', (
tester,
) async {
final harness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Synchronisation en attente'), findsOneWidget);
await tester.tap(find.text('Synchroniser maintenant'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(find.textContaining('Dernière synchro :'), findsOneWidget);
});
testWidgets('partages reçus ouvre la boîte de réception', (tester) async {
final harness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Partages reçus'));
await tester.pumpAndSettle();
expect(find.text('Aucun partage reçu'), findsOneWidget);
});
testWidgets(
'la section Sauvegarde locale est visible déconnecté et connecté',
(tester) async {
final harness = _AuthHarness();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Sauvegarde locale'), findsOneWidget);
expect(find.text('Exporter mes données'), findsOneWidget);
expect(find.text('Importer des données'), findsOneWidget);
final connectedHarness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: connectedHarness.useCases,
syncUseCases: connectedHarness.syncUseCases,
shareUseCases: connectedHarness.shareUseCases,
dataExportUseCase: connectedHarness.dataExportUseCase,
dataImportUseCase: connectedHarness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Sauvegarde locale'), findsOneWidget);
expect(find.text('Exporter mes données'), findsOneWidget);
expect(find.text('Importer des données'), findsOneWidget);
},
);
testWidgets('export réussi affiche le message de sauvegarde exportée', (
tester,
) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(status: ShareResultStatus.success);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
),
);
await tester.pump();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
});
testWidgets('un échec dexport affiche un message neutre', (tester) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter()..throwOnExport = true;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
),
);
await tester.pump();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
expect(find.text('Export impossible pour le moment.'), findsOneWidget);
});
testWidgets('import fusion réussie affiche les données importées', (
tester,
) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.text('Importer cette sauvegarde ?'), findsOneWidget);
expect(find.text('Fusionner'), findsOneWidget);
expect(find.text('Remplacer tout'), findsOneWidget);
await tester.tap(find.text('Fusionner'));
await tester.pumpAndSettle();
expect(find.text('Données importées.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.merge,
]);
});
testWidgets('remplacer tout exige une seconde confirmation destructive', (
tester,
) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Remplacer toutes les données locales ?'), findsOneWidget);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
});
testWidgets('un fichier invalide affiche le message dédié', (tester) async {
final harness = _AuthHarness();
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'notes.txt',
bytes: Uint8List.fromList(utf8.encode('{"kind":"something.else"}')),
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.text('Fichier invalide'), findsOneWidget);
expect(
find.text('Ce fichier nest pas une sauvegarde GameTime valide.'),
findsOneWidget,
);
});
testWidgets('une séance active bloque limport', (tester) async {
final harness = _AuthHarness()
..backupRepository.hasOpenSession = true
..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.text('Importer cette sauvegarde ?'), findsOneWidget);
await tester.tap(find.text('Fusionner'));
await tester.pumpAndSettle();
expect(find.text('Séance en cours'), findsOneWidget);
expect(
find.text(
'Termine ou sauvegarde ta séance avant dimporter des données.',
),
findsOneWidget,
);
});
testWidgets('annuler la sélection de fichier reste silencieux', (
tester,
) async {
final harness = _AuthHarness();
final picker = _FakeBackupFilePicker();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(find.byType(SnackBar), findsNothing);
});
}
final class _AuthHarness {
_AuthHarness({UserAccountSession? initialSession, String? initialToken})
: tokenStore = _FakeAuthTokenStore(initialToken),
accountRepository = _FakeOnlineAccountRepository(initialSession),
remote = _FakeRemoteAuthApi(),
syncRemote = _FakeRemoteSyncApi(),
syncMetadata = _FakeSyncMetadataRepository(),
syncMappings = _FakeRemoteResourceMappingRepository(),
syncChanges = _FakeLocalSyncChangeRepository(),
shareRemote = _FakeRemoteShareApi(),
shareInbox = _FakeShareInboxRepository(),
pendingShares = _FakePendingShareActionRepository(),
programRepository = _FakeProgramRepository(),
templateRepository = _FakeWorkoutTemplateRepository(),
backupRepository = _FakeLocalDataBackupRepository(),
backupMediaStore = _FakeLocalBackupMediaStore() {
useCases = AuthUseCases(
tokenStore: tokenStore,
accountRepository: accountRepository,
remoteAuthApi: remote,
clock: const _FakeClock(),
ids: _FakeIds(),
);
syncUseCases = SyncUseCases(
tokenStore: tokenStore,
remoteSyncApi: syncRemote,
metadataRepository: syncMetadata,
mappingRepository: syncMappings,
localChanges: syncChanges,
clock: const _FakeClock(),
deviceId: 'device-1',
);
shareUseCases = ShareUseCases(
tokenStore: tokenStore,
remoteShareApi: shareRemote,
inboxRepository: shareInbox,
pendingActionRepository: pendingShares,
localChanges: syncChanges,
programRepository: programRepository,
templateRepository: templateRepository,
clock: const _FakeClock(),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
dataExportUseCase = DataExportUseCase(
repository: backupRepository,
mediaStore: backupMediaStore,
clock: const _FakeClock(),
);
dataImportUseCase = DataImportUseCase(
repository: backupRepository,
mediaStore: backupMediaStore,
clock: const _FakeClock(),
);
}
factory _AuthHarness.connected() {
return _AuthHarness(
initialSession: UserAccountSession(
id: 'account-1',
serverUserId: 'server-user-1',
email: 'alex@example.com',
displayName: 'Alex',
isLoggedIn: true,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
),
initialToken: 'token-1',
);
}
final _FakeAuthTokenStore tokenStore;
final _FakeOnlineAccountRepository accountRepository;
final _FakeRemoteAuthApi remote;
final _FakeRemoteSyncApi syncRemote;
final _FakeSyncMetadataRepository syncMetadata;
final _FakeRemoteResourceMappingRepository syncMappings;
final _FakeLocalSyncChangeRepository syncChanges;
final _FakeRemoteShareApi shareRemote;
final _FakeShareInboxRepository shareInbox;
final _FakePendingShareActionRepository pendingShares;
final _FakeProgramRepository programRepository;
final _FakeWorkoutTemplateRepository templateRepository;
final _FakeLocalDataBackupRepository backupRepository;
final _FakeLocalBackupMediaStore backupMediaStore;
late final AuthUseCases useCases;
late final SyncUseCases syncUseCases;
late final ShareUseCases shareUseCases;
late final DataExportUseCase dataExportUseCase;
late final DataImportUseCase dataImportUseCase;
}
final class _FakeClock implements Clock {
const _FakeClock();
@override
DateTime now() => DateTime.utc(2026, 7, 17, 12);
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'local-account-$_next';
}
}
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 _FakeOnlineAccountRepository implements OnlineAccountRepository {
_FakeOnlineAccountRepository(this.session);
UserAccountSession? session;
@override
Future<void> clearSession() async {
session = null;
}
@override
Future<UserAccountSession?> currentSession() async => session;
@override
Future<void> saveSession(UserAccountSession session) async {
this.session = session;
}
}
final class _FakeRemoteAuthApi implements RemoteAuthApi {
RemoteAuthFailure? loginFailure;
RemoteAuthFailure? registerFailure;
@override
Future<RemoteAuthResult> login({
required String email,
required String password,
}) async {
final failure = loginFailure;
if (failure != null) {
throw RemoteAuthException(failure);
}
return RemoteAuthResult(
userId: 'server-user-1',
email: email.trim(),
token: 'token-1',
expiresAt: DateTime.utc(2026, 7, 18),
);
}
@override
Future<void> logout(String token) async {}
@override
Future<RemoteAuthResult> register({
required String email,
required String password,
String? displayName,
}) async {
final failure = registerFailure;
if (failure != null) {
throw RemoteAuthException(failure);
}
return RemoteAuthResult(
userId: 'server-user-1',
email: email.trim(),
token: 'token-1',
expiresAt: DateTime.utc(2026, 7, 18),
);
}
}
final class _FakeRemoteSyncApi implements RemoteSyncApi {
var pullCalls = 0;
var pushCalls = 0;
var throwOnPull = false;
@override
Future<RemoteSyncPushResult> push({
required String deviceId,
required List<RemoteSyncPushItem> items,
required String token,
}) async {
pushCalls += 1;
return const RemoteSyncPushResult(serverCursor: null, results: []);
}
@override
Future<RemoteSyncPullResult> pull({
required String? since,
required String token,
}) async {
pullCalls += 1;
if (throwOnPull) {
throw Exception('temporary unavailable');
}
return const RemoteSyncPullResult(serverCursor: 'cursor-1', items: []);
}
}
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
var metadata = const SyncMetadataSnapshot();
@override
Future<SyncMetadataSnapshot> read() async => metadata;
@override
Future<void> save(SyncMetadataSnapshot metadata) async {
this.metadata = metadata;
}
}
final class _FakeRemoteResourceMappingRepository
implements RemoteResourceMappingRepository {
final _mappings = <String, RemoteResourceMapping>{};
@override
Future<RemoteResourceMapping?> find({
required SyncResourceType resourceType,
required String clientId,
}) async {
return _mappings['${resourceType.name}:$clientId'];
}
@override
Future<void> save(RemoteResourceMapping mapping) async {
_mappings['${mapping.resourceType.name}:${mapping.clientId}'] = mapping;
}
}
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 {}
}
final class _FakeRemoteShareApi implements RemoteShareApi {
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'program-remote',
serverId: 'program-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
);
}
@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 {
return const RemoteShareSendResult(
shareId: 'share-1',
recipientUserIds: ['recipient-1'],
unresolvedEmails: [],
);
}
}
final class _FakeShareInboxRepository implements ShareInboxRepository {
final items = <ShareInboxItem>[];
@override
Future<ShareInboxItem?> findByShareId(String shareId) async {
return items.cast<ShareInboxItem?>().firstWhere(
(item) => item?.shareId == shareId,
orElse: () => null,
);
}
@override
Future<List<ShareInboxItem>> listAll() async => List.of(items);
@override
Future<void> markStatus(
String shareId,
ShareInboxStatus status,
DateTime respondedAt,
) async {
final index = items.indexWhere((item) => item.shareId == shareId);
if (index == -1) return;
items[index] = items[index].copyWith(
status: status,
respondedAt: respondedAt,
);
}
@override
Future<void> upsert(ShareInboxItem item) async {
items.removeWhere((existing) => existing.shareId == item.shareId);
items.add(item);
}
@override
Future<void> upsertAll(List<ShareInboxItem> items) async {
for (final item in items) {
await upsert(item);
}
}
}
final class _FakePendingShareActionRepository
implements PendingShareActionRepository {
final actions = <PendingShareAction>[];
@override
Future<void> add(PendingShareAction action) async {
actions.add(action);
}
@override
Future<List<PendingShareAction>> listPending() async => actions;
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) 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 _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 _FakeLocalDataBackupRepository
implements LocalDataBackupRepository {
var hasOpenSession = false;
var hasData = false;
var throwOnApply = false;
final appliedModes = <LocalBackupImportMode>[];
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
if (throwOnApply) {
throw Exception('transaction failed');
}
appliedModes.add(mode);
return const LocalBackupImportResult(
insertedCount: 1,
updatedCount: 0,
ignoredOlderCount: 0,
deletedByReplaceCount: 0,
missingMediaCount: 0,
);
}
@override
Future<bool> hasAnyUserData() async => hasData;
@override
Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
return LocalDataExportSnapshot(
exportedAt: exportedAt,
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
);
}
}
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
@override
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
List<LocalBackupResource> mediaAssets,
) async => const [];
@override
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
List<EmbeddedBackupMediaFile> files,
) async => const {};
}
final class _FakeBackupFileExporter implements LocalBackupFileExporter {
_FakeBackupFileExporter({this.status = ShareResultStatus.success});
ShareResultStatus status;
String? exportedFileName;
Uint8List? exportedBytes;
var throwOnExport = false;
@override
Future<ShareResultStatus> exportFile({
required String fileName,
required Uint8List bytes,
}) async {
if (throwOnExport) {
throw Exception('share failed');
}
exportedFileName = fileName;
exportedBytes = bytes;
return status;
}
}
final class _FakeBackupFilePicker implements LocalBackupFilePicker {
_FakeBackupFilePicker({this.file});
LocalBackupPickedFile? file;
@override
Future<LocalBackupPickedFile?> pickFile() async => file;
}