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:
448
lib/presentation/widgets/comments_bottom_sheet.dart
Normal file
448
lib/presentation/widgets/comments_bottom_sheet.dart
Normal file
@@ -0,0 +1,448 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../core/constants/design_system.dart';
|
||||
import '../../core/utils/calculate_time_ago.dart';
|
||||
import '../../data/datasources/social_remote_data_source.dart';
|
||||
import '../../data/services/secure_storage.dart';
|
||||
import '../../domain/entities/comment.dart';
|
||||
import 'animated_widgets.dart';
|
||||
import 'custom_snackbar.dart';
|
||||
import 'shimmer_loading.dart';
|
||||
|
||||
/// Bottom sheet moderne pour afficher et ajouter des commentaires.
|
||||
///
|
||||
/// Ce widget affiche tous les commentaires d'un post et permet
|
||||
/// d'ajouter de nouveaux commentaires avec une interface élégante.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// showCommentsBottomSheet(
|
||||
/// context: context,
|
||||
/// postId: '123',
|
||||
/// );
|
||||
/// ```
|
||||
class CommentsBottomSheet extends StatefulWidget {
|
||||
const CommentsBottomSheet({
|
||||
required this.postId,
|
||||
required this.onCommentAdded,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// ID du post
|
||||
final String postId;
|
||||
|
||||
/// Callback appelé quand un commentaire est ajouté
|
||||
final VoidCallback onCommentAdded;
|
||||
|
||||
/// Méthode statique pour afficher le bottom sheet
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required String postId,
|
||||
required VoidCallback onCommentAdded,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => CommentsBottomSheet(
|
||||
postId: postId,
|
||||
onCommentAdded: onCommentAdded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<CommentsBottomSheet> createState() => _CommentsBottomSheetState();
|
||||
}
|
||||
|
||||
class _CommentsBottomSheetState extends State<CommentsBottomSheet> {
|
||||
final SocialRemoteDataSource _dataSource = SocialRemoteDataSource(http.Client());
|
||||
final SecureStorage _secureStorage = SecureStorage();
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
final FocusNode _commentFocusNode = FocusNode();
|
||||
|
||||
List<Comment> _comments = [];
|
||||
bool _isLoading = true;
|
||||
bool _isSubmitting = false;
|
||||
String? _currentUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadComments();
|
||||
_loadCurrentUser();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commentController.dispose();
|
||||
_commentFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentUser() async {
|
||||
final userId = await _secureStorage.getUserId();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentUserId = userId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadComments() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final comments = await _dataSource.getComments(widget.postId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_comments = comments.map((model) => model.toEntity()).toList();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
context.showError('Erreur lors du chargement des commentaires');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitComment() async {
|
||||
final content = _commentController.text.trim();
|
||||
if (content.isEmpty) {
|
||||
context.showWarning('Le commentaire ne peut pas être vide');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentUserId == null) {
|
||||
context.showWarning('Vous devez être connecté pour commenter');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final newComment = await _dataSource.createComment(
|
||||
postId: widget.postId,
|
||||
content: content,
|
||||
userId: _currentUserId!,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_comments.insert(0, newComment.toEntity());
|
||||
_isSubmitting = false;
|
||||
_commentController.clear();
|
||||
});
|
||||
_commentFocusNode.unfocus();
|
||||
widget.onCommentAdded();
|
||||
context.showSuccess('Commentaire ajouté');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
context.showError('Erreur lors de l\'ajout du commentaire');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteComment(Comment comment, int index) async {
|
||||
try {
|
||||
await _dataSource.deleteComment(widget.postId, comment.id);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_comments.removeAt(index);
|
||||
});
|
||||
widget.onCommentAdded();
|
||||
context.showSuccess('Commentaire supprimé');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
context.showError('Erreur lors de la suppression');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return Container(
|
||||
height: mediaQuery.size.height * 0.75,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(DesignSystem.radiusLg),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: DesignSystem.spacingSm),
|
||||
width: 36,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
||||
),
|
||||
),
|
||||
|
||||
// Header
|
||||
Padding(
|
||||
padding: DesignSystem.paddingHorizontal(DesignSystem.spacingLg),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Commentaires',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 17,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_comments.length}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Divider(
|
||||
height: 20,
|
||||
thickness: 1,
|
||||
color: theme.dividerColor.withOpacity(0.5),
|
||||
),
|
||||
|
||||
// Liste des commentaires
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const SkeletonList(
|
||||
itemCount: 3,
|
||||
skeletonWidget: ListItemSkeleton(),
|
||||
)
|
||||
: _comments.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 56,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.2),
|
||||
),
|
||||
const SizedBox(height: DesignSystem.spacingMd),
|
||||
Text(
|
||||
'Aucun commentaire',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignSystem.spacingSm),
|
||||
Text(
|
||||
'Soyez le premier à commenter',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: DesignSystem.paddingAll(DesignSystem.spacingMd),
|
||||
itemCount: _comments.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 20,
|
||||
thickness: 1,
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final comment = _comments[index];
|
||||
return _buildCommentItem(comment, index, theme);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Input pour ajouter un commentaire
|
||||
Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: DesignSystem.spacingLg,
|
||||
right: DesignSystem.spacingLg,
|
||||
top: DesignSystem.spacingMd,
|
||||
bottom: mediaQuery.viewInsets.bottom + DesignSystem.spacingMd,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _commentController,
|
||||
focusNode: _commentFocusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ajouter un commentaire...',
|
||||
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignSystem.spacingMd,
|
||||
vertical: DesignSystem.spacingSm,
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 14),
|
||||
maxLines: null,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
enabled: !_isSubmitting,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignSystem.spacingSm),
|
||||
AnimatedScaleButton(
|
||||
onTap: _isSubmitting ? () {} : _submitComment,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DesignSystem.spacingSm),
|
||||
decoration: BoxDecoration(
|
||||
color: _isSubmitting
|
||||
? theme.colorScheme.primary.withOpacity(0.5)
|
||||
: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: _isSubmitting
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send_rounded,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentItem(Comment comment, int index, ThemeData theme) {
|
||||
final isCurrentUser = comment.userId == _currentUserId;
|
||||
|
||||
return FadeInWidget(
|
||||
delay: Duration(milliseconds: index * 50),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: comment.userProfileImageUrl.isNotEmpty
|
||||
? NetworkImage(comment.userProfileImageUrl)
|
||||
: null,
|
||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.15),
|
||||
child: comment.userProfileImageUrl.isEmpty
|
||||
? Icon(
|
||||
Icons.person_rounded,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 18,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: DesignSystem.spacingMd),
|
||||
|
||||
// Contenu du commentaire
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom et timestamp
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
comment.authorFullName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
calculateTimeAgo(comment.timestamp),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Contenu
|
||||
Text(
|
||||
comment.content,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton supprimer (si c'est le commentaire de l'utilisateur)
|
||||
if (isCurrentUser) ...[
|
||||
const SizedBox(width: DesignSystem.spacingSm),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline_rounded, size: 18),
|
||||
color: theme.colorScheme.error.withOpacity(0.7),
|
||||
onPressed: () => _deleteComment(comment, index),
|
||||
tooltip: 'Supprimer',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user