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

@@ -1,119 +1,394 @@
import 'package:flutter/material.dart';
import '../../../core/constants/colors.dart';
import '../../../data/models/social_post_model.dart';
import 'social_badge_widget.dart'; // Import du widget BadgeWidget
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/constants/design_system.dart';
import '../../../domain/entities/social_post.dart';
import '../widgets/custom_snackbar.dart';
/// Widget d'en-tête pour les posts sociaux avec design moderne.
///
/// Affiche l'avatar, le nom de l'utilisateur, le timestamp et les options.
class SocialHeaderWidget extends StatelessWidget {
final SocialPost post;
final VoidCallback onEditPost;
final VoidCallback onClosePost; // Ajout du callback pour la fermeture du post
final GlobalKey menuKey;
final BuildContext menuContext;
const SocialHeaderWidget({
Key? key,
required this.post,
required this.onEditPost,
required this.onClosePost, // Initialisation du callback de fermeture
required this.onClosePost,
required this.menuKey,
required this.menuContext,
}) : super(key: key);
this.showVerifiedBadge = false,
super.key,
});
final SocialPost post;
final VoidCallback onEditPost;
final VoidCallback onClosePost;
final GlobalKey menuKey;
final BuildContext menuContext;
final bool showVerifiedBadge;
@override
Widget build(BuildContext context) {
return Stack(
final theme = Theme.of(context);
return Row(
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.grey.shade800,
backgroundImage: post.userImage.isNotEmpty
? AssetImage(post.userImage)
: const AssetImage('lib/assets/images/placeholder.png'),
radius: 22,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// Avatar avec bordure gradient (style Instagram)
_buildAvatar(theme),
const SizedBox(width: DesignSystem.spacingMd),
// Informations utilisateur
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Nom avec badge de vérification
Row(
children: [
Text(
post.userName,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
post.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
children: post.badges
.map((badge) => BadgeWidget(badge: badge))
.toList(),
),
if (showVerifiedBadge) ...[
const SizedBox(width: 4),
Icon(
Icons.verified,
size: 16,
color: theme.colorScheme.primary,
),
],
],
),
),
],
),
// Ajout des boutons dans le coin supérieur droit
Positioned(
top: 0,
right: 0,
child: Row(
mainAxisSize: MainAxisSize.min, // Réduit la taille du Row au minimum
children: [
IconButton(
key: menuKey,
icon: const Icon(Icons.more_vert, color: Colors.white54, size: 20),
splashRadius: 20,
onPressed: () {
_showOptionsMenu(menuContext, menuKey);
},
),
const SizedBox(width: 4), // Espacement entre les boutons
IconButton(
icon: const Icon(Icons.close, color: Colors.white54, size: 20),
splashRadius: 20,
onPressed: onClosePost, // Appel du callback de fermeture
// Timestamp
const SizedBox(height: 2),
Text(
_formatTimestamp(post.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 12,
height: 1.2,
),
),
],
),
),
// Menu options
IconButton(
key: menuKey,
icon: const Icon(Icons.more_horiz_rounded),
iconSize: 22,
onPressed: () => _showOptionsMenu(menuContext),
color: theme.colorScheme.onSurface.withOpacity(0.6),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
);
}
void _showOptionsMenu(BuildContext context, GlobalKey menuKey) {
showModalBottomSheet(
/// Construit l'avatar avec bordure dégradée
Widget _buildAvatar(ThemeData theme) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withOpacity(0.3),
theme.colorScheme.secondary.withOpacity(0.3),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(2),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.scaffoldBackgroundColor,
),
padding: const EdgeInsets.all(2),
child: CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.surfaceVariant,
backgroundImage: _getProfileImage(),
child: _getProfileImage() == null
? Icon(
Icons.person_rounded,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
),
);
}
/// Récupère l'image de profil de l'utilisateur
ImageProvider? _getProfileImage() {
if (post.userProfileImageUrl.isEmpty) {
return null;
}
if (post.userProfileImageUrl.startsWith('http://') ||
post.userProfileImageUrl.startsWith('https://')) {
return NetworkImage(post.userProfileImageUrl);
}
return AssetImage(post.userProfileImageUrl);
}
/// Formate le timestamp de manière relative
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inSeconds < 60) {
return 'À l\'instant';
} else if (difference.inMinutes < 60) {
return 'Il y a ${difference.inMinutes} min';
} else if (difference.inHours < 24) {
return 'Il y a ${difference.inHours}h';
} else if (difference.inDays < 7) {
return 'Il y a ${difference.inDays}j';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return 'Il y a ${weeks}sem';
} else {
return DateFormat('d MMM', 'fr_FR').format(timestamp);
}
}
/// Affiche le menu des options
void _showOptionsMenu(BuildContext context) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
final theme = Theme.of(context);
return Container(
color: AppColors.backgroundColor,
child: Wrap(
children: <Widget>[
ListTile(
leading: Icon(Icons.edit, color: AppColors.iconPrimary),
title: const Text('Modifier'),
onTap: () {
Navigator.of(context).pop();
onEditPost();
},
),
ListTile(
leading: Icon(Icons.delete, color: AppColors.iconPrimary),
title: const Text('Supprimer'),
onTap: () {
Navigator.of(context).pop();
},
),
],
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(
top: DesignSystem.spacingMd,
bottom: DesignSystem.spacingSm,
),
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
// Options
_buildMenuOption(
context,
theme,
Icons.edit_outlined,
'Modifier',
onEditPost,
),
_buildMenuOption(
context,
theme,
Icons.link_rounded,
'Copier le lien',
() {
_copyPostLink(context);
},
),
_buildMenuOption(
context,
theme,
Icons.share_outlined,
'Partager',
() {
_sharePost(context);
},
),
Divider(
height: 1,
thickness: 1,
color: theme.dividerColor.withOpacity(0.5),
),
_buildMenuOption(
context,
theme,
Icons.report_outlined,
'Signaler',
() {
_reportPost(context);
},
isDestructive: false,
textColor: theme.colorScheme.error,
),
const SizedBox(height: DesignSystem.spacingSm),
],
),
),
);
},
);
}
/// Construit une option du menu
Widget _buildMenuOption(
BuildContext context,
ThemeData theme,
IconData icon,
String title,
VoidCallback onTap, {
bool isDestructive = false,
Color? textColor,
}) {
final color = textColor ?? (isDestructive ? theme.colorScheme.error : null);
return ListTile(
leading: Icon(
icon,
color: color,
size: 22,
),
title: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontSize: 14,
),
),
onTap: () {
Navigator.of(context).pop();
onTap();
},
contentPadding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingXs,
),
);
}
/// Copie le lien du post dans le presse-papiers
void _copyPostLink(BuildContext context) {
final postUrl = 'https://afterwork.app/post/${post.id}';
Clipboard.setData(ClipboardData(text: postUrl));
context.showSuccess('Lien copié dans le presse-papiers');
}
/// Partage le post via la fonctionnalité native de partage
Future<void> _sharePost(BuildContext context) async {
try {
final postUrl = 'https://afterwork.app/post/${post.id}';
final shareText = '${post.content}\n\n$postUrl';
await Share.share(
shareText,
subject: 'Publication AfterWork',
);
} catch (e) {
if (context.mounted) {
context.showError('Erreur lors du partage');
}
}
}
/// Affiche le dialog de signalement
void _reportPost(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler ce post'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pourquoi signalez-vous ce post ?',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: DesignSystem.spacingMd),
_buildReportOption(context, 'Contenu inapproprié'),
_buildReportOption(context, 'Spam ou arnaque'),
_buildReportOption(context, 'Harcèlement'),
_buildReportOption(context, 'Fausses informations'),
_buildReportOption(context, 'Autre'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
);
}
/// Construit une option de signalement
Widget _buildReportOption(BuildContext context, String reason) {
return InkWell(
onTap: () {
Navigator.of(context).pop();
_submitReport(context, reason);
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: DesignSystem.spacingSm,
horizontal: DesignSystem.spacingXs,
),
child: Row(
children: [
Icon(
Icons.report_outlined,
size: 20,
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
),
const SizedBox(width: DesignSystem.spacingMd),
Text(
reason,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
/// Soumet le signalement
Future<void> _submitReport(BuildContext context, String reason) async {
// Simuler l'envoi du signalement
// Dans une vraie application, cela ferait un appel API
await Future.delayed(const Duration(milliseconds: 500));
if (context.mounted) {
context.showSuccess('Signalement envoyé. Merci pour votre contribution.');
}
}
}