feat(online): statut de synchronisation discret et action manuelle (ticket #68)
Affiche le statut de synchronisation sur profile_screen.dart et home_screen.dart avec une action de déclenchement manuel, en formulation neutre (jamais "Erreur"/"Échec" ni couleur alarmante : "Synchronisation en attente", "Nouvelle tentative automatique"). flutter pub get OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 127/127 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -125,6 +125,10 @@ final class SyncUseCases {
|
|||||||
final Clock clock;
|
final Clock clock;
|
||||||
final String deviceId;
|
final String deviceId;
|
||||||
|
|
||||||
|
Future<SyncMetadataSnapshot> currentStatus() {
|
||||||
|
return metadataRepository.read();
|
||||||
|
}
|
||||||
|
|
||||||
Future<SyncRunSummary> synchronize({required bool manual}) async {
|
Future<SyncRunSummary> synchronize({required bool manual}) async {
|
||||||
final token = await tokenStore.readToken();
|
final token = await tokenStore.readToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
|
|||||||
@ -202,8 +202,10 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) =>
|
builder: (context) => ProfileScreen(
|
||||||
ProfileScreen(authUseCases: widget.bootstrap.authUseCases),
|
authUseCases: widget.bootstrap.authUseCases,
|
||||||
|
syncUseCases: widget.bootstrap.syncUseCases,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -5,9 +5,14 @@ import '../domain/domain.dart';
|
|||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
final class ProfileScreen extends StatefulWidget {
|
final class ProfileScreen extends StatefulWidget {
|
||||||
const ProfileScreen({required this.authUseCases, super.key});
|
const ProfileScreen({
|
||||||
|
required this.authUseCases,
|
||||||
|
required this.syncUseCases,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final AuthUseCases authUseCases;
|
final AuthUseCases authUseCases;
|
||||||
|
final SyncUseCases syncUseCases;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||||
@ -41,6 +46,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
}
|
}
|
||||||
return _SignedInProfile(
|
return _SignedInProfile(
|
||||||
session: session,
|
session: session,
|
||||||
|
syncUseCases: widget.syncUseCases,
|
||||||
onLogout: () => _confirmLogout(context),
|
onLogout: () => _confirmLogout(context),
|
||||||
onShares: () => _openReceivedShares(context),
|
onShares: () => _openReceivedShares(context),
|
||||||
);
|
);
|
||||||
@ -52,7 +58,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
Future<void> _openLogin(BuildContext context) async {
|
Future<void> _openLogin(BuildContext context) async {
|
||||||
final connected = await Navigator.of(context).push<bool>(
|
final connected = await Navigator.of(context).push<bool>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => LoginScreen(authUseCases: widget.authUseCases),
|
builder: (context) => LoginScreen(
|
||||||
|
authUseCases: widget.authUseCases,
|
||||||
|
syncUseCases: widget.syncUseCases,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (connected == true && mounted) {
|
if (connected == true && mounted) {
|
||||||
@ -68,7 +77,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
Future<void> _openRegister(BuildContext context) async {
|
Future<void> _openRegister(BuildContext context) async {
|
||||||
final connected = await Navigator.of(context).push<bool>(
|
final connected = await Navigator.of(context).push<bool>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => RegisterScreen(authUseCases: widget.authUseCases),
|
builder: (context) => RegisterScreen(
|
||||||
|
authUseCases: widget.authUseCases,
|
||||||
|
syncUseCases: widget.syncUseCases,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (connected == true && mounted) {
|
if (connected == true && mounted) {
|
||||||
@ -198,11 +210,13 @@ final class _SignedOutProfile extends StatelessWidget {
|
|||||||
final class _SignedInProfile extends StatelessWidget {
|
final class _SignedInProfile extends StatelessWidget {
|
||||||
const _SignedInProfile({
|
const _SignedInProfile({
|
||||||
required this.session,
|
required this.session,
|
||||||
|
required this.syncUseCases,
|
||||||
required this.onLogout,
|
required this.onLogout,
|
||||||
required this.onShares,
|
required this.onShares,
|
||||||
});
|
});
|
||||||
|
|
||||||
final UserAccountSession session;
|
final UserAccountSession session;
|
||||||
|
final SyncUseCases syncUseCases;
|
||||||
final VoidCallback onLogout;
|
final VoidCallback onLogout;
|
||||||
final VoidCallback onShares;
|
final VoidCallback onShares;
|
||||||
|
|
||||||
@ -242,17 +256,7 @@ final class _SignedInProfile extends StatelessWidget {
|
|||||||
label: const Text('Se déconnecter'),
|
label: const Text('Se déconnecter'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
CourtBlazerAccentPanel(
|
SyncStatusPanel(syncUseCases: syncUseCases),
|
||||||
child: Material(
|
|
||||||
type: MaterialType.transparency,
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.cloud_outlined),
|
|
||||||
title: const Text('Synchronisation'),
|
|
||||||
subtitle: const Text('Synchronisation : à venir'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
CourtBlazerAccentPanel(
|
CourtBlazerAccentPanel(
|
||||||
child: Material(
|
child: Material(
|
||||||
@ -274,10 +278,147 @@ final class _SignedInProfile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class SyncStatusPanel extends StatefulWidget {
|
||||||
|
const SyncStatusPanel({required this.syncUseCases, super.key});
|
||||||
|
|
||||||
|
final SyncUseCases syncUseCases;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SyncStatusPanel> createState() => _SyncStatusPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _SyncStatusPanelState extends State<SyncStatusPanel> {
|
||||||
|
late Future<SyncMetadataSnapshot> _status;
|
||||||
|
var _syncing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_status = widget.syncUseCases.currentStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CourtBlazerAccentPanel(
|
||||||
|
child: FutureBuilder<SyncMetadataSnapshot>(
|
||||||
|
future: _status,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final status = snapshot.data;
|
||||||
|
final view = _syncStatusView(status, _syncing);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: _syncing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Icon(view.icon),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Synchronisation',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(view.label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _syncing ? null : _syncNow,
|
||||||
|
icon: const Icon(Icons.sync),
|
||||||
|
label: const Text('Synchroniser maintenant'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNow() async {
|
||||||
|
setState(() {
|
||||||
|
_syncing = true;
|
||||||
|
});
|
||||||
|
await widget.syncUseCases.synchronize(manual: true);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_syncing = false;
|
||||||
|
_status = widget.syncUseCases.currentStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _SyncStatusView {
|
||||||
|
const _SyncStatusView({required this.label, required this.icon});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
_SyncStatusView _syncStatusView(SyncMetadataSnapshot? metadata, bool syncing) {
|
||||||
|
if (syncing || metadata?.status == OnlineSyncStatus.syncing) {
|
||||||
|
return const _SyncStatusView(
|
||||||
|
label: 'Synchronisation en cours...',
|
||||||
|
icon: Icons.sync,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (metadata == null) {
|
||||||
|
return const _SyncStatusView(
|
||||||
|
label: 'Synchronisation en attente',
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final lastSuccess = metadata.lastSuccessfulSyncAt;
|
||||||
|
if (metadata.status == OnlineSyncStatus.success && lastSuccess != null) {
|
||||||
|
return _SyncStatusView(
|
||||||
|
label: 'Dernière synchro : ${_relativeSyncTime(lastSuccess)}',
|
||||||
|
icon: Icons.cloud_done_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (metadata.status == OnlineSyncStatus.failure) {
|
||||||
|
if (lastSuccess != null) {
|
||||||
|
return _SyncStatusView(
|
||||||
|
label:
|
||||||
|
'Dernière synchronisation : ${_relativeSyncTime(lastSuccess)}. '
|
||||||
|
'Nouvelle tentative automatique.',
|
||||||
|
icon: Icons.schedule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const _SyncStatusView(
|
||||||
|
label: 'Synchronisation en attente',
|
||||||
|
icon: Icons.schedule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const _SyncStatusView(
|
||||||
|
label: 'Synchronisation en attente',
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final class LoginScreen extends StatefulWidget {
|
final class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({required this.authUseCases, super.key});
|
const LoginScreen({
|
||||||
|
required this.authUseCases,
|
||||||
|
required this.syncUseCases,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final AuthUseCases authUseCases;
|
final AuthUseCases authUseCases;
|
||||||
|
final SyncUseCases syncUseCases;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LoginScreen> createState() => _LoginScreenState();
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
@ -338,6 +479,7 @@ final class _LoginScreenState extends State<LoginScreen> {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => RegisterScreen(
|
builder: (context) => RegisterScreen(
|
||||||
authUseCases: widget.authUseCases,
|
authUseCases: widget.authUseCases,
|
||||||
|
syncUseCases: widget.syncUseCases,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -386,9 +528,14 @@ final class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class RegisterScreen extends StatefulWidget {
|
final class RegisterScreen extends StatefulWidget {
|
||||||
const RegisterScreen({required this.authUseCases, super.key});
|
const RegisterScreen({
|
||||||
|
required this.authUseCases,
|
||||||
|
required this.syncUseCases,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final AuthUseCases authUseCases;
|
final AuthUseCases authUseCases;
|
||||||
|
final SyncUseCases syncUseCases;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||||
@ -472,8 +619,10 @@ final class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
: () {
|
: () {
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) =>
|
builder: (context) => LoginScreen(
|
||||||
LoginScreen(authUseCases: widget.authUseCases),
|
authUseCases: widget.authUseCases,
|
||||||
|
syncUseCases: widget.syncUseCases,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -619,6 +768,32 @@ String _registerErrorMessage(RemoteAuthFailure failure) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _relativeSyncTime(DateTime value) {
|
||||||
|
final localValue = value.toLocal();
|
||||||
|
final difference = DateTime.now().difference(localValue);
|
||||||
|
if (difference.inMinutes < 1) {
|
||||||
|
return 'à l’instant';
|
||||||
|
}
|
||||||
|
if (difference.inHours < 1) {
|
||||||
|
return 'il y a ${difference.inMinutes} min';
|
||||||
|
}
|
||||||
|
if (difference.inDays < 1) {
|
||||||
|
return 'il y a ${difference.inHours} h';
|
||||||
|
}
|
||||||
|
if (difference.inDays == 1) {
|
||||||
|
return 'hier à ${_formatHourMinute(localValue)}';
|
||||||
|
}
|
||||||
|
return 'le ${localValue.day.toString().padLeft(2, '0')}/'
|
||||||
|
'${localValue.month.toString().padLeft(2, '0')} '
|
||||||
|
'à ${_formatHourMinute(localValue)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatHourMinute(DateTime value) {
|
||||||
|
final hour = value.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = value.minute.toString().padLeft(2, '0');
|
||||||
|
return '$hour:$minute';
|
||||||
|
}
|
||||||
|
|
||||||
String _profileInitials(String value) {
|
String _profileInitials(String value) {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) return '?';
|
if (trimmed.isEmpty) return '?';
|
||||||
|
|||||||
@ -11,7 +11,12 @@ void main() {
|
|||||||
final harness = _AuthHarness();
|
final harness = _AuthHarness();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
|
MaterialApp(
|
||||||
|
home: ProfileScreen(
|
||||||
|
authUseCases: harness.useCases,
|
||||||
|
syncUseCases: harness.syncUseCases,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@ -28,7 +33,12 @@ void main() {
|
|||||||
..remote.loginFailure = RemoteAuthFailure.invalidCredentials;
|
..remote.loginFailure = RemoteAuthFailure.invalidCredentials;
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
|
MaterialApp(
|
||||||
|
home: ProfileScreen(
|
||||||
|
authUseCases: harness.useCases,
|
||||||
|
syncUseCases: harness.syncUseCases,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@ -55,7 +65,12 @@ void main() {
|
|||||||
final harness = _AuthHarness();
|
final harness = _AuthHarness();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
|
MaterialApp(
|
||||||
|
home: ProfileScreen(
|
||||||
|
authUseCases: harness.useCases,
|
||||||
|
syncUseCases: harness.syncUseCases,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@ -92,7 +107,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)),
|
MaterialApp(
|
||||||
|
home: ProfileScreen(
|
||||||
|
authUseCases: harness.useCases,
|
||||||
|
syncUseCases: harness.syncUseCases,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@ -107,13 +127,94 @@ void main() {
|
|||||||
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
|
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
|
||||||
expect(find.text('Compte optionnel'), findsOneWidget);
|
expect(find.text('Compte optionnel'), 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final class _AuthHarness {
|
final class _AuthHarness {
|
||||||
_AuthHarness({UserAccountSession? initialSession, String? initialToken})
|
_AuthHarness({UserAccountSession? initialSession, String? initialToken})
|
||||||
: tokenStore = _FakeAuthTokenStore(initialToken),
|
: tokenStore = _FakeAuthTokenStore(initialToken),
|
||||||
accountRepository = _FakeOnlineAccountRepository(initialSession),
|
accountRepository = _FakeOnlineAccountRepository(initialSession),
|
||||||
remote = _FakeRemoteAuthApi() {
|
remote = _FakeRemoteAuthApi(),
|
||||||
|
syncRemote = _FakeRemoteSyncApi(),
|
||||||
|
syncMetadata = _FakeSyncMetadataRepository(),
|
||||||
|
syncMappings = _FakeRemoteResourceMappingRepository(),
|
||||||
|
syncChanges = _FakeLocalSyncChangeRepository() {
|
||||||
useCases = AuthUseCases(
|
useCases = AuthUseCases(
|
||||||
tokenStore: tokenStore,
|
tokenStore: tokenStore,
|
||||||
accountRepository: accountRepository,
|
accountRepository: accountRepository,
|
||||||
@ -121,12 +222,41 @@ final class _AuthHarness {
|
|||||||
clock: const _FakeClock(),
|
clock: const _FakeClock(),
|
||||||
ids: _FakeIds(),
|
ids: _FakeIds(),
|
||||||
);
|
);
|
||||||
|
syncUseCases = SyncUseCases(
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
remoteSyncApi: syncRemote,
|
||||||
|
metadataRepository: syncMetadata,
|
||||||
|
mappingRepository: syncMappings,
|
||||||
|
localChanges: syncChanges,
|
||||||
|
clock: const _FakeClock(),
|
||||||
|
deviceId: 'device-1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _FakeAuthTokenStore tokenStore;
|
||||||
final _FakeOnlineAccountRepository accountRepository;
|
final _FakeOnlineAccountRepository accountRepository;
|
||||||
final _FakeRemoteAuthApi remote;
|
final _FakeRemoteAuthApi remote;
|
||||||
|
final _FakeRemoteSyncApi syncRemote;
|
||||||
|
final _FakeSyncMetadataRepository syncMetadata;
|
||||||
|
final _FakeRemoteResourceMappingRepository syncMappings;
|
||||||
|
final _FakeLocalSyncChangeRepository syncChanges;
|
||||||
late final AuthUseCases useCases;
|
late final AuthUseCases useCases;
|
||||||
|
late final SyncUseCases syncUseCases;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class _FakeClock implements Clock {
|
final class _FakeClock implements Clock {
|
||||||
@ -226,3 +356,76 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user