fix(chat): Correction race condition + Implémentation TODOs

## Corrections Critiques

### Race Condition - Statuts de Messages
- Fix : Les icônes de statut (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas
- Cause : WebSocket delivery confirmations arrivaient avant messages locaux
- Solution : Pattern Optimistic UI dans chat_bloc.dart
  - Création message temporaire immédiate
  - Ajout à la liste AVANT requête HTTP
  - Remplacement par message serveur à la réponse
- Fichier : lib/presentation/state_management/chat_bloc.dart

## Implémentation TODOs (13/21)

### Social (social_header_widget.dart)
-  Copier lien du post dans presse-papiers
-  Partage natif via Share.share()
-  Dialogue de signalement avec 5 raisons

### Partage (share_post_dialog.dart)
-  Interface sélection d'amis avec checkboxes
-  Partage externe via Share API

### Média (media_upload_service.dart)
-  Parsing JSON réponse backend
-  Méthode deleteMedia() pour suppression
-  Génération miniature vidéo

### Posts (create_post_dialog.dart, edit_post_dialog.dart)
-  Extraction URL depuis uploads
-  Documentation chargement médias

### Chat (conversations_screen.dart)
-  Navigation vers notifications
-  ConversationSearchDelegate pour recherche

## Nouveaux Fichiers

### Configuration
- build-prod.ps1 : Script build production avec dart-define
- lib/core/constants/env_config.dart : Gestion environnements

### Documentation
- TODOS_IMPLEMENTED.md : Documentation complète TODOs

## Améliorations

### Architecture
- Refactoring injection de dépendances
- Amélioration routing et navigation
- Optimisation providers (UserProvider, FriendsProvider)

### UI/UX
- Amélioration thème et couleurs
- Optimisation animations
- Meilleure gestion erreurs

### Services
- Configuration API avec env_config
- Amélioration datasources (events, users)
- Optimisation modèles de données
This commit is contained in:
dahoud
2026-01-10 10:43:17 +00:00
parent 06031b01f2
commit 92612abbd7
321 changed files with 43137 additions and 4285 deletions

View File

@@ -0,0 +1,395 @@
import 'package:afterwork/data/datasources/event_remote_data_source.dart';
import 'package:afterwork/data/models/event_model.dart';
import 'package:afterwork/presentation/state_management/event_bloc.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// Mock classes
class MockEventRemoteDataSource extends Mock
implements EventRemoteDataSource {}
void main() {
late EventBloc eventBloc;
late MockEventRemoteDataSource mockRemoteDataSource;
setUpAll(() {
// Enregistrer fallback value pour EventModel
registerFallbackValue(
EventModel(
id: 'fallback',
title: 'Fallback',
description: 'Fallback',
startDate: '2026-01-01T00:00:00Z',
location: 'Fallback',
category: 'Fallback',
link: 'Fallback',
creatorEmail: 'fallback@example.com',
creatorFirstName: 'Fallback',
creatorLastName: 'Fallback',
profileImageUrl: 'fallback.jpg',
participants: const [],
status: 'ouvert',
reactionsCount: 0,
commentsCount: 0,
sharesCount: 0,
),
);
});
setUp(() {
mockRemoteDataSource = MockEventRemoteDataSource();
eventBloc = EventBloc(remoteDataSource: mockRemoteDataSource);
});
tearDown(() {
eventBloc.close();
});
final tEventModels = [
EventModel(
id: '1',
title: 'Event 1',
description: 'Description 1',
startDate: '2026-01-15T19:00:00Z',
location: 'Location 1',
category: 'Category 1',
link: 'https://link1.com',
creatorEmail: 'creator1@example.com',
creatorFirstName: 'John',
creatorLastName: 'Doe',
profileImageUrl: 'profile1.jpg',
participants: const [],
status: 'ouvert',
reactionsCount: 0,
commentsCount: 0,
sharesCount: 0,
),
EventModel(
id: '2',
title: 'Event 2',
description: 'Description 2',
startDate: '2026-01-16T19:00:00Z',
location: 'Location 2',
category: 'Category 2',
link: 'https://link2.com',
creatorEmail: 'creator2@example.com',
creatorFirstName: 'Jane',
creatorLastName: 'Smith',
profileImageUrl: 'profile2.jpg',
participants: const [],
status: 'ouvert',
reactionsCount: 0,
commentsCount: 0,
sharesCount: 0,
),
];
group('EventBloc', () {
test('initial state should be EventInitial', () {
// Assert
expect(eventBloc.state, isA<EventInitial>());
});
group('LoadEvents', () {
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventLoaded] when data is gotten successfully',
build: () {
when(
() => mockRemoteDataSource
.getEventsCreatedByUserAndFriends(any()),
).thenAnswer((_) async => tEventModels);
return eventBloc;
},
act: (bloc) => bloc.add(const LoadEvents('user123')),
expect: () => [
isA<EventLoading>(),
isA<EventLoaded>().having((s) => s.events, 'events', tEventModels),
],
verify: (_) {
verify(
() => mockRemoteDataSource
.getEventsCreatedByUserAndFriends('user123'),
).called(1);
},
);
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventError] when getting data fails',
build: () {
when(
() => mockRemoteDataSource
.getEventsCreatedByUserAndFriends(any()),
).thenThrow(Exception('Failed to load events'));
return eventBloc;
},
act: (bloc) => bloc.add(const LoadEvents('user123')),
expect: () => [
isA<EventLoading>(),
isA<EventError>()
.having((s) => s.message, 'message', 'Erreur lors du chargement des événements.'),
],
);
blocTest<EventBloc, EventState>(
'should emit empty list when no events found',
build: () {
when(
() => mockRemoteDataSource
.getEventsCreatedByUserAndFriends(any()),
).thenAnswer((_) async => []);
return eventBloc;
},
act: (bloc) => bloc.add(const LoadEvents('user123')),
expect: () => [
isA<EventLoading>(),
isA<EventLoaded>().having((s) => s.events, 'events', isEmpty),
],
);
});
group('AddEvent', () {
final tNewEvent = EventModel(
id: '3',
title: 'New Event',
description: 'New Description',
startDate: '2026-01-17T19:00:00Z',
location: 'New Location',
category: 'New Category',
link: 'https://newlink.com',
creatorEmail: 'new@example.com',
creatorFirstName: 'New',
creatorLastName: 'Creator',
profileImageUrl: 'newprofile.jpg',
participants: const [],
status: 'ouvert',
reactionsCount: 0,
commentsCount: 0,
sharesCount: 0,
);
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventLoaded] when event is added successfully',
build: () {
when(() => mockRemoteDataSource.createEvent(any()))
.thenAnswer((_) async => tNewEvent);
when(() => mockRemoteDataSource.getAllEvents())
.thenAnswer((_) async => [...tEventModels, tNewEvent]);
return eventBloc;
},
act: (bloc) => bloc.add(AddEvent(tNewEvent)),
expect: () => [
isA<EventLoading>(),
isA<EventLoaded>()
.having((s) => s.events.length, 'events.length', 3)
.having((s) => s.events.last.id, 'last event id', '3'),
],
);
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventError] when adding event fails',
build: () {
when(() => mockRemoteDataSource.createEvent(any()))
.thenThrow(Exception('Failed to create event'));
return eventBloc;
},
act: (bloc) => bloc.add(AddEvent(tNewEvent)),
expect: () => [
isA<EventLoading>(),
isA<EventError>()
.having((s) => s.message, 'message', "Erreur lors de l'ajout de l'événement."),
],
);
});
group('CloseEvent', () {
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventLoaded] with updated event status',
build: () {
when(() => mockRemoteDataSource.closeEvent(any()))
.thenAnswer((_) async => {});
return eventBloc;
},
seed: () => EventLoaded(tEventModels),
act: (bloc) => bloc.add(const CloseEvent('1')),
wait: const Duration(milliseconds: 100),
expect: () => [
isA<EventLoading>(),
predicate<EventState>((state) {
if (state is EventLoaded) {
try {
final event = state.events.firstWhere((e) => e.id == '1');
return event.status == 'fermé';
} catch (_) {
return false;
}
}
return false;
}),
],
);
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventError] when closing event fails',
build: () {
when(() => mockRemoteDataSource.closeEvent(any()))
.thenThrow(Exception('Failed to close event'));
return eventBloc;
},
seed: () => EventLoaded(tEventModels),
act: (bloc) => bloc.add(const CloseEvent('1')),
expect: () => [
isA<EventLoading>(),
isA<EventError>()
.having((s) => s.message, 'message', "Erreur lors de la fermeture de l'événement."),
],
);
blocTest<EventBloc, EventState>(
'should not update state when event is not in loaded list',
build: () {
when(() => mockRemoteDataSource.closeEvent(any()))
.thenAnswer((_) async => {});
return eventBloc;
},
seed: () => EventLoaded(tEventModels),
act: (bloc) => bloc.add(const CloseEvent('non-existent-id')),
expect: () => [
isA<EventLoading>(),
isA<EventLoaded>(), // Restaure l'état précédent
],
);
});
group('ReopenEvent', () {
final closedEvents = tEventModels.map((e) {
final event = EventModel(
id: e.id,
title: e.title,
description: e.description,
startDate: e.startDate,
location: e.location,
category: e.category,
link: e.link,
creatorEmail: e.creatorEmail,
creatorFirstName: e.creatorFirstName,
creatorLastName: e.creatorLastName,
profileImageUrl: e.profileImageUrl,
participants: e.participants,
status: 'fermé',
reactionsCount: e.reactionsCount,
commentsCount: e.commentsCount,
sharesCount: e.sharesCount,
);
return event;
}).toList();
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventLoaded] with reopened event',
build: () {
when(() => mockRemoteDataSource.reopenEvent(any()))
.thenAnswer((_) async => {});
return eventBloc;
},
seed: () => EventLoaded(closedEvents),
act: (bloc) => bloc.add(const ReopenEvent('1')),
wait: const Duration(milliseconds: 100),
expect: () => [
isA<EventLoading>(),
predicate<EventState>((state) {
if (state is EventLoaded) {
try {
final event = state.events.firstWhere((e) => e.id == '1');
return event.status == 'ouvert';
} catch (_) {
return false;
}
}
return false;
}),
],
);
blocTest<EventBloc, EventState>(
'should emit [EventLoading, EventError] when reopening event fails',
build: () {
when(() => mockRemoteDataSource.reopenEvent(any()))
.thenThrow(Exception('Failed to reopen event'));
return eventBloc;
},
seed: () => EventLoaded(closedEvents),
act: (bloc) => bloc.add(const ReopenEvent('1')),
expect: () => [
isA<EventLoading>(),
isA<EventError>()
.having((s) => s.message, 'message', "Erreur lors de la réouverture de l'événement."),
],
);
blocTest<EventBloc, EventState>(
'should not update state when reopening non-existent event',
build: () {
when(() => mockRemoteDataSource.reopenEvent(any()))
.thenAnswer((_) async => {});
return eventBloc;
},
seed: () => EventLoaded(closedEvents),
act: (bloc) => bloc.add(const ReopenEvent('non-existent-id')),
expect: () => [
isA<EventLoading>(),
isA<EventLoaded>(), // Restaure l'état précédent
],
);
});
group('RemoveEvent', () {
blocTest<EventBloc, EventState>(
'should remove event from list locally',
build: () => eventBloc,
seed: () => EventLoaded(tEventModels),
act: (bloc) => bloc.add(const RemoveEvent('1')),
skip: 0,
expect: () => [
isA<EventLoaded>().having(
(s) => s.events.length,
'events.length',
1,
).having(
(s) => s.events.first.id,
'first event id',
'2',
),
],
);
blocTest<EventBloc, EventState>(
'should not emit anything when state is not EventLoaded',
build: () => eventBloc,
seed: () => const EventInitial(),
act: (bloc) => bloc.add(const RemoveEvent('1')),
expect: () => [],
);
test('should handle removing non-existent event gracefully', () async {
// Arrange
final bloc = eventBloc;
final initialEvents = tEventModels;
// Act
bloc.emit(EventLoaded(initialEvents));
bloc.add(const RemoveEvent('non-existent-id'));
// Wait for the event to be processed
await Future.delayed(const Duration(milliseconds: 100));
// Assert
final currentState = bloc.state;
expect(currentState, isA<EventLoaded>());
if (currentState is EventLoaded) {
expect(currentState.events.length, 2); // La liste reste inchangée
}
});
});
});
}