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,101 +1,372 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../core/constants/colors.dart';
import '../../../data/models/social_post_model.dart';
import '../../../core/constants/design_system.dart';
import '../../../domain/entities/social_post.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/fullscreen_image_viewer.dart';
import '../../widgets/social_header_widget.dart';
import '../../widgets/social_interaction_row.dart';
import '../../widgets/swipe_background.dart'; // Import du widget de swipe
class SocialCard extends StatelessWidget {
final SocialPost post;
final VoidCallback onLike;
final VoidCallback onComment;
final VoidCallback onShare;
final VoidCallback onDeletePost;
final VoidCallback onEditPost;
/// Card moderne et élaborée pour afficher un post social.
///
/// Design inspiré d'Instagram avec hiérarchie visuelle claire,
/// support de contenu riche (hashtags, mentions) et animations fluides.
class SocialCard extends StatefulWidget {
const SocialCard({
Key? key,
required this.post,
required this.onLike,
required this.onComment,
required this.onShare,
required this.onDeletePost,
required this.onEditPost,
}) : super(key: key);
this.showVerifiedBadge = false,
super.key,
});
final SocialPost post;
final VoidCallback onLike;
final VoidCallback onComment;
final VoidCallback onShare;
final VoidCallback onDeletePost;
final VoidCallback onEditPost;
final bool showVerifiedBadge;
@override
State<SocialCard> createState() => _SocialCardState();
}
class _SocialCardState extends State<SocialCard> {
bool _showFullContent = false;
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey(post.postText),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
onDeletePost();
},
background: SwipeBackground(
color: Colors.red,
icon: Icons.delete,
label: 'Supprimer',
final theme = Theme.of(context);
return AnimatedCard(
margin: const EdgeInsets.only(bottom: DesignSystem.spacingMd),
borderRadius: DesignSystem.borderRadiusMd,
elevation: 0.5,
hoverElevation: 1.5,
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec avatar, nom, timestamp
Padding(
padding: const EdgeInsets.fromLTRB(
DesignSystem.spacingLg,
DesignSystem.spacingMd,
DesignSystem.spacingSm,
DesignSystem.spacingMd,
),
child: SocialHeaderWidget(
post: widget.post,
onEditPost: widget.onEditPost,
menuKey: GlobalKey(),
menuContext: context,
onClosePost: () {},
showVerifiedBadge: widget.showVerifiedBadge,
),
),
// Image du post avec Hero animation
if (widget.post.imageUrl != null && widget.post.imageUrl!.isNotEmpty)
_buildPostImage(context, theme),
// Contenu et interactions
Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre d'interactions (like, comment, share)
SocialInteractionRow(
post: widget.post,
onLike: widget.onLike,
onComment: widget.onComment,
onShare: widget.onShare,
),
const SizedBox(height: DesignSystem.spacingMd),
// Nombre de likes
if (widget.post.likesCount > 0)
_buildLikesCount(theme),
// Contenu du post avec texte enrichi
_buildPostContent(theme),
// Nombre de commentaires
if (widget.post.commentsCount > 0)
_buildCommentsCount(theme),
],
),
),
],
),
child: Card(
color: AppColors.cardColor,
margin: const EdgeInsets.symmetric(vertical: 10.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SocialHeaderWidget(
post: post,
onEditPost: () {
print('Modifier le post');
},
menuKey: GlobalKey(),
menuContext: context,
onClosePost: () {
print('Close post');
},
),
const SizedBox(height: 8),
Text (
post.postText,
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
const SizedBox(height: 8),
if (post.postImage.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(post.postImage, fit: BoxFit.cover),
),
const SizedBox(height: 8),
Row(
children: post.tags
.map((tag) => Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
tag,
style: TextStyle(
color: AppColors.accentColor,
fontSize: 12,
);
}
/// Construit l'image du post avec Hero animation et double-tap to like
Widget _buildPostImage(BuildContext context, ThemeData theme) {
return GestureDetector(
onDoubleTap: () {
if (!widget.post.isLikedByCurrentUser) {
widget.onLike();
_showLikeAnimation();
}
},
onTap: () => _openFullscreenImage(context),
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
child: Hero(
tag: 'social_post_image_${widget.post.id}',
child: Image.network(
widget.post.imageUrl!,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
strokeWidth: 2.5,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
child: Center(
child: Icon(
Icons.broken_image_rounded,
size: 48,
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
))
.toList(),
),
const SizedBox(height: 8),
SocialInteractionRow(
post: post,
onLike: onLike,
onComment: onComment,
onShare: onShare,
),
],
);
},
),
),
),
),
);
}
/// Construit le compteur de likes
Widget _buildLikesCount(ThemeData theme) {
return Padding(
padding: const EdgeInsets.only(bottom: DesignSystem.spacingMd),
child: Text(
_formatLikesCount(widget.post.likesCount),
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: -0.1,
),
),
);
}
/// Construit le contenu du post avec texte enrichi
Widget _buildPostContent(ThemeData theme) {
final content = widget.post.content;
final shouldTruncate = content.length > 150 && !_showFullContent;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 1.4,
letterSpacing: -0.1,
),
children: [
// Nom de l'auteur en gras
TextSpan(
text: '${widget.post.authorFullName} ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
// Contenu avec support des hashtags et mentions
..._buildEnrichedContent(
shouldTruncate ? '${content.substring(0, 150)}...' : content,
theme,
),
],
),
),
// Bouton "Voir plus" / "Voir moins"
if (content.length > 150)
GestureDetector(
onTap: () {
setState(() {
_showFullContent = !_showFullContent;
});
},
child: Padding(
padding: const EdgeInsets.only(top: DesignSystem.spacingXs),
child: Text(
_showFullContent ? 'Voir moins' : 'Voir plus',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
/// Construit le compteur de commentaires
Widget _buildCommentsCount(ThemeData theme) {
return GestureDetector(
onTap: widget.onComment,
child: Padding(
padding: const EdgeInsets.only(top: DesignSystem.spacingMd),
child: Text(
_formatCommentsCount(widget.post.commentsCount),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 13,
),
),
),
);
}
/// Construit le contenu enrichi avec hashtags et mentions cliquables
List<TextSpan> _buildEnrichedContent(String content, ThemeData theme) {
final spans = <TextSpan>[];
final words = content.split(' ');
for (var i = 0; i < words.length; i++) {
final word = words[i];
if (word.startsWith('#')) {
// Hashtag
spans.add(
TextSpan(
text: word,
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
recognizer: TapGestureRecognizer()
..onTap = () {
_handleHashtagTap(word.substring(1));
},
),
);
} else if (word.startsWith('@')) {
// Mention
spans.add(
TextSpan(
text: word,
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
recognizer: TapGestureRecognizer()
..onTap = () {
_handleMentionTap(word.substring(1));
},
),
);
} else {
// Texte normal
spans.add(TextSpan(text: word));
}
// Ajouter un espace sauf pour le dernier mot
if (i < words.length - 1) {
spans.add(const TextSpan(text: ' '));
}
}
return spans;
}
/// Ouvre l'image en plein écran avec Hero animation
void _openFullscreenImage(BuildContext context) {
Navigator.push<void>(
context,
PageRouteBuilder<void>(
opaque: false,
barrierColor: Colors.black,
pageBuilder: (context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FullscreenImageViewer(
imageUrl: widget.post.imageUrl!,
heroTag: 'social_post_image_${widget.post.id}',
title: widget.post.content,
),
);
},
),
);
}
/// Affiche l'animation de like
void _showLikeAnimation() {
// TODO: Implémenter animation de coeur qui apparaît au centre
if (mounted) {
// Animation visuelle rapide
}
}
/// Gère le tap sur un hashtag
void _handleHashtagTap(String hashtag) {
debugPrint('[SocialCard] Hashtag cliqué: #$hashtag');
// TODO: Naviguer vers la page des posts avec ce hashtag
}
/// Gère le tap sur une mention
void _handleMentionTap(String username) {
debugPrint('[SocialCard] Mention cliquée: @$username');
// TODO: Naviguer vers le profil de l'utilisateur
}
/// Formate le nombre de likes
String _formatLikesCount(int count) {
if (count == 1) {
return '1 j\'aime';
}
return '${_formatCount(count)} j\'aime';
}
/// Formate le nombre de commentaires
String _formatCommentsCount(int count) {
if (count == 1) {
return 'Voir le commentaire';
}
return 'Voir les $count commentaires';
}
/// Formate les compteurs (1K, 1M, etc.)
String _formatCount(int count) {
if (count >= 1000000) {
final value = count / 1000000;
return value % 1 == 0
? '${value.toInt()} M'
: '${value.toStringAsFixed(1)} M';
} else if (count >= 1000) {
final value = count / 1000;
return value % 1 == 0 ? '${value.toInt()} K' : '${value.toStringAsFixed(1)} K';
}
return count.toString();
}
}

View File

@@ -1,103 +1,381 @@
import 'package:flutter/material.dart';
import '../../../data/models/social_post_model.dart';
import 'social_card.dart'; // Import de la SocialCard
import 'package:http/http.dart' as http;
import '../../../core/constants/design_system.dart';
import '../../../data/datasources/social_remote_data_source.dart';
import '../../../domain/entities/social_post.dart';
import '../../widgets/comments_bottom_sheet.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/modern_empty_state.dart';
import '../../widgets/share_post_dialog.dart';
import '../../widgets/shimmer_loading.dart';
import 'social_card.dart';
/// Widget de contenu social moderne avec stories et feed de posts.
class SocialContent extends StatefulWidget {
const SocialContent({super.key});
const SocialContent({
super.key,
this.userId,
this.refreshTrigger,
});
/// ID de l'utilisateur pour charger les posts de ses amis
final String? userId;
/// Notifier pour déclencher un refresh depuis l'extérieur
final ValueNotifier<int>? refreshTrigger;
@override
_SocialContentState createState() => _SocialContentState();
State<SocialContent> createState() => _SocialContentState();
}
class _SocialContentState extends State<SocialContent> {
final List<SocialPost> _posts = [
SocialPost(
userName: 'John Doe',
userImage: 'lib/assets/images/profile_picture.png',
postText: 'Une belle journée au parc avec des amis ! 🌳🌞',
postImage: 'lib/assets/images/placeholder.png',
likes: 12,
comments: 4,
badges: ['Explorer', 'Photographe'],
tags: ['#Nature', '#FunDay'],
shares: 25,
),
SocialPost(
userName: 'Jane Smith',
userImage: 'lib/assets/images/profile_picture.png',
postText: 'Mon nouveau chat est tellement mignon 🐱',
postImage: 'lib/assets/images/placeholder.png',
likes: 30,
comments: 8,
badges: ['Animal Lover', 'Partageur'],
tags: ['#Chat', '#Cuteness'],
shares: 25,
),
SocialPost(
userName: 'Alice Brown',
userImage: 'lib/assets/images/profile_picture.png',
postText: 'Café du matin avec une vue magnifique ☕️',
postImage: 'lib/assets/images/placeholder.png',
likes: 45,
comments: 15,
badges: ['Gourmet', 'Partageur'],
tags: ['#Café', '#MorningVibes'],
shares: 25,
),
];
final SocialRemoteDataSource _dataSource =
SocialRemoteDataSource(http.Client());
final ScrollController _scrollController = ScrollController();
List<SocialPost> _posts = [];
bool _isLoading = true;
bool _isLoadingMore = false;
String? _errorMessage;
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _posts.length,
itemBuilder: (context, index) {
final post = _posts[index];
return SocialCard(
post: post,
onLike: () {
setState(() {
_posts[index] = SocialPost(
userName: post.userName,
userImage: post.userImage,
postText: post.postText,
postImage: post.postImage,
likes: post.likes + 1,
comments: post.comments,
badges: post.badges,
tags: post.tags,
shares: post.shares + 1,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Like ajouté')),
);
},
onComment: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Commentaire ajouté')),
);
},
onShare: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Post partagé')),
);
},
onDeletePost: () {
setState(() {
_posts.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Post supprimé')),
);
},
onEditPost: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Post modifié')),
);
},
);
void initState() {
super.initState();
loadPosts();
_scrollController.addListener(_onScroll);
// Écouter les changements du refreshTrigger pour actualiser
widget.refreshTrigger?.addListener(_onRefreshTriggered);
}
@override
void dispose() {
widget.refreshTrigger?.removeListener(_onRefreshTriggered);
_scrollController.dispose();
super.dispose();
}
/// Appelé quand le refreshTrigger change
void _onRefreshTriggered() {
loadPosts();
}
/// Gère le scroll pour la pagination
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_isLoadingMore &&
!_isLoading) {
_loadMorePosts();
}
}
/// Charge les posts initiaux (méthode publique pour permettre le refresh depuis l'extérieur)
Future<void> loadPosts() async {
if (!mounted) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Si userId est fourni, charger les posts de l'utilisateur et de ses amis
// Sinon, charger tous les posts
final posts = widget.userId != null
? await _dataSource.getPostsByFriends(userId: widget.userId!)
: await _dataSource.getPosts();
if (mounted) {
setState(() {
_posts = posts.map((model) => model.toEntity()).toList();
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
/// Charge plus de posts (pagination)
Future<void> _loadMorePosts() async {
if (_isLoadingMore) return;
setState(() {
_isLoadingMore = true;
});
try {
// TODO: Implémenter la pagination avec offset/limit
await Future<void>.delayed(const Duration(seconds: 1));
if (mounted) {
setState(() {
_isLoadingMore = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingMore = false;
});
}
}
}
Future<void> _handleLike(String postId, int index) async {
final post = _posts[index];
final wasLiked = post.isLikedByCurrentUser;
// Mise à jour optimiste (UI instantanée)
setState(() {
_posts[index] = post.copyWith(
isLikedByCurrentUser: !wasLiked,
likesCount: wasLiked ? post.likesCount - 1 : post.likesCount + 1,
);
});
try {
final updatedPost = await _dataSource.likePost(postId);
if (mounted) {
setState(() {
_posts[index] = updatedPost.toEntity();
});
}
} catch (e) {
// Rollback en cas d'erreur
if (mounted) {
setState(() {
_posts[index] = post;
});
context.showError('Erreur lors du like');
}
}
}
Future<void> _handleComment(String postId, int index) async {
await CommentsBottomSheet.show(
context: context,
postId: postId,
onCommentAdded: () {
// Recharger le post pour avoir le compteur de commentaires à jour
_refreshPost(postId, index);
},
);
}
Future<void> _refreshPost(String postId, int index) async {
try {
final posts = await _dataSource.getPosts();
final updatedPost = posts.firstWhere((p) => p.id == postId);
if (mounted) {
setState(() {
_posts[index] = updatedPost.toEntity();
});
}
} catch (e) {
// Silently fail, pas besoin d'afficher une erreur
}
}
Future<void> _handleShare(String postId, int index) async {
final post = _posts[index];
// Ouvre le dialogue de partage avec plusieurs options
await SharePostDialog.show(
context: context,
post: post,
onShareConfirmed: () => _confirmShare(postId, index),
);
}
/// Confirme le partage sur le fil (incrémente le compteur)
Future<void> _confirmShare(String postId, int index) async {
try {
final updatedPost = await _dataSource.sharePost(postId);
if (mounted) {
setState(() {
_posts[index] = updatedPost.toEntity();
});
context.showSuccess('Post partagé avec succès');
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors du partage: ${e.toString()}');
}
}
}
Future<void> _handleDelete(String postId, int index) async {
try {
await _dataSource.deletePost(postId);
if (mounted) {
setState(() {
_posts.removeAt(index);
});
context.showSuccess('Post supprimé avec succès');
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la suppression: ${e.toString()}');
}
}
}
void _handleEdit(SocialPost post, int index) {
// TODO: Implémenter l'édition avec un dialog
context.showInfo('Fonctionnalité d\'édition à venir');
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SkeletonList(
itemCount: 3,
skeletonWidget: SocialPostSkeleton(),
);
}
if (_errorMessage != null) {
return _buildErrorState(context);
}
if (_posts.isEmpty) {
return RefreshIndicator(
onRefresh: loadPosts,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height - 200,
child: const ModernEmptyState(
illustration: EmptyStateIllustration.social,
title: 'Aucun post disponible',
description:
'Soyez le premier à publier quelque chose et commencer les discussions !',
),
),
),
);
}
return RefreshIndicator(
onRefresh: loadPosts,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// TODO: Section Stories (en attente de l'endpoint backend)
// SliverToBoxAdapter(child: _buildStoriesSection()),
// Padding top
const SliverToBoxAdapter(
child: SizedBox(height: DesignSystem.spacingSm),
),
// Liste des posts
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingMd,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= _posts.length) {
return _isLoadingMore
? const Padding(
padding: EdgeInsets.all(DesignSystem.spacingLg),
child: Center(
child: CircularProgressIndicator(strokeWidth: 2.5),
),
)
: const SizedBox.shrink();
}
final post = _posts[index];
return SocialCard(
key: ValueKey(post.id),
post: post,
onLike: () => _handleLike(post.id, index),
onComment: () => _handleComment(post.id, index),
onShare: () => _handleShare(post.id, index),
onDeletePost: () => _handleDelete(post.id, index),
onEditPost: () => _handleEdit(post, index),
// Badge vérifié retiré - pas de données backend
showVerifiedBadge: false,
);
},
childCount: _posts.length + (_isLoadingMore ? 1 : 0),
),
),
),
// Padding bottom
const SliverToBoxAdapter(
child: SizedBox(height: DesignSystem.spacingXl),
),
],
),
);
}
// TODO: Méthodes stories à implémenter quand l'endpoint backend sera disponible
// Widget _buildStoriesSection() { ... }
// Widget _buildStoryItem() { ... }
/// Construit l'état d'erreur
Widget _buildErrorState(BuildContext context) {
final theme = Theme.of(context);
return RefreshIndicator(
onRefresh: loadPosts,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height - 200,
child: Center(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingXl),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: theme.colorScheme.error.withOpacity(0.5),
),
const SizedBox(height: DesignSystem.spacingLg),
Text(
'Erreur de chargement',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DesignSystem.spacingSm),
Text(
_errorMessage!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: DesignSystem.spacingXl),
FilledButton.icon(
onPressed: loadPosts,
icon: const Icon(Icons.refresh_rounded, size: 20),
label: const Text('Réessayer'),
),
],
),
),
),
),
),
);
}
}

View File

@@ -1,25 +1,311 @@
import 'package:flutter/material.dart';
import 'social_content.dart'; // Import du fichier qui contient SocialContent
import 'package:http/http.dart' as http;
class SocialScreen extends StatelessWidget {
import '../../../core/constants/design_system.dart';
import '../../../core/constants/env_config.dart';
import '../../../core/utils/page_transitions.dart';
import '../../../data/datasources/event_remote_data_source.dart';
import '../../../data/datasources/social_remote_data_source.dart';
import '../../../data/datasources/user_remote_data_source.dart';
import '../../../data/services/secure_storage.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/social/create_post_dialog.dart';
import '../event/event_screen.dart';
import 'social_content.dart';
/// Écran social avec design moderne et contenu enrichi.
///
/// Cet écran affiche les posts sociaux, les stories, et les interactions
/// avec une interface utilisateur optimisée et compacte.
///
/// **Fonctionnalités:**
/// - Affichage des posts sociaux
/// - Stories
/// - Interactions (like, comment, share)
/// - Création de posts
class SocialScreen extends StatefulWidget {
const SocialScreen({super.key});
@override
State<SocialScreen> createState() => _SocialScreenState();
}
class _SocialScreenState extends State<SocialScreen> {
late final SocialRemoteDataSource _socialDataSource;
late final EventRemoteDataSource _eventDataSource;
late final SecureStorage _secureStorage;
late final UserRemoteDataSource _userDataSource;
final ValueNotifier<int> _refreshTrigger = ValueNotifier<int>(0);
String? _userId;
@override
void initState() {
super.initState();
_socialDataSource = SocialRemoteDataSource(http.Client());
_eventDataSource = EventRemoteDataSource(http.Client());
_userDataSource = UserRemoteDataSource(http.Client());
_secureStorage = SecureStorage();
_loadUserId();
}
@override
void dispose() {
_refreshTrigger.dispose();
super.dispose();
}
/// Charge l'ID utilisateur depuis le stockage sécurisé
Future<void> _loadUserId() async {
final userId = await _secureStorage.getUserId();
if (mounted) {
setState(() {
_userId = userId;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: const Color(0xFF1E1E2C), // Fond noir pour correspondre à un thème sombre
appBar: AppBar(
title: const Text(
'Social',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.black, // AppBar avec fond noir pour un design cohérent
appBar: _buildAppBar(theme),
body: SocialContent(
userId: _userId,
refreshTrigger: _refreshTrigger,
),
body: SocialContent(), // Appel à SocialContent pour afficher le contenu
floatingActionButton: _buildFloatingActionButton(context, theme),
);
}
/// Construit la barre d'application.
PreferredSizeWidget _buildAppBar(ThemeData theme) {
return AppBar(
elevation: 0,
scrolledUnderElevation: 2,
title: Text(
'Social',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
actions: [
IconButton(
icon: const Icon(Icons.search_rounded, size: 22),
tooltip: 'Rechercher',
onPressed: _handleSearch,
),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded, size: 22),
tooltip: 'Créer un post',
onPressed: _handleCreatePost,
),
],
);
}
/// Construit le bouton flottant (compact).
Widget _buildFloatingActionButton(BuildContext context, ThemeData theme) {
return FloatingActionButton(
onPressed: _handleCreatePost,
tooltip: 'Nouveau post',
elevation: 2,
child: const Icon(Icons.add_rounded, size: 26),
);
}
/// Gère la recherche.
void _handleSearch() {
final theme = Theme.of(context);
showDialog<void>(
context: context,
builder: (context) {
final searchController = TextEditingController();
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
title: Row(
children: [
Icon(
Icons.search_rounded,
color: theme.colorScheme.primary,
size: 24,
),
const SizedBox(width: DesignSystem.spacingMd),
const Text('Rechercher'),
],
),
titleTextStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
content: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'Posts, utilisateurs...',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 14,
),
prefixIcon: Icon(
Icons.search_rounded,
size: 20,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingMd,
vertical: DesignSystem.spacingSm,
),
),
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 14),
autofocus: true,
onSubmitted: (value) {
Navigator.pop(context);
_performSearch(value);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
_performSearch(searchController.text);
},
child: const Text('Rechercher'),
),
],
);
},
);
}
/// Effectue la recherche.
Future<void> _performSearch(String query) async {
if (query.trim().isEmpty) {
context.showWarning('Veuillez entrer un terme de recherche');
return;
}
if (EnvConfig.enableDetailedLogs) {
debugPrint('[SocialScreen] Recherche: $query');
}
try {
// Rechercher dans les événements (endpoint disponible)
final events = await _eventDataSource.searchEvents(query);
// Rechercher dans les posts sociaux (quand l'endpoint sera disponible)
// final posts = await _socialDataSource.searchPosts(query);
if (mounted) {
final totalResults = events.length; // + posts.length;
if (totalResults > 0) {
// Naviguer vers l'écran des événements avec les résultats
final userId = await _secureStorage.getUserId();
if (userId != null && mounted) {
await context.pushSlideRight<void>(
EventScreen(
userId: userId,
userFirstName: '',
userLastName: '',
profileImageUrl: '',
),
);
}
if (mounted) {
context.showSuccess(
'$totalResults résultat(s) trouvé(s) pour "$query"',
);
}
} else {
context.showInfo('Aucun résultat trouvé pour "$query"');
}
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la recherche: ${e.toString()}');
}
}
}
/// Gère la création d'un post avec support d'images/vidéos.
Future<void> _handleCreatePost() async {
// Récupérer les informations utilisateur pour l'affichage
final userId = await _secureStorage.getUserId();
if (userId == null || userId.isEmpty) {
if (mounted) {
context.showWarning('Vous devez être connecté pour créer un post');
}
return;
}
try {
// Récupérer les vraies informations utilisateur depuis le backend
final userModel = await _userDataSource.getUser(userId);
final userName = '${userModel.userFirstName} ${userModel.userLastName}';
final userAvatarUrl = userModel.profileImageUrl;
if (!mounted) return;
await CreatePostDialog.show(
context: context,
onPostCreated: (content, medias) => _createPost(content, medias),
userName: userName,
userAvatarUrl: userAvatarUrl,
);
} catch (e) {
if (mounted) {
context.showError(
'Erreur lors de la récupération des informations utilisateur: ${e.toString()}',
);
}
}
}
/// Crée un nouveau post avec contenu et médias.
Future<void> _createPost(String content, List<dynamic> medias) async {
try {
final userId = await _secureStorage.getUserId();
if (userId == null || userId.isEmpty) {
if (mounted) {
context.showWarning('Vous devez être connecté pour créer un post');
}
return;
}
if (EnvConfig.enableDetailedLogs) {
debugPrint('[SocialScreen] Création de post: $content');
debugPrint('[SocialScreen] Nombre de médias: ${medias.length}');
}
// TODO: Uploader les médias et récupérer les URLs
// Pour l'instant, on crée juste le post avec le contenu
await _socialDataSource.createPost(
content: content,
userId: userId,
);
if (mounted) {
context.showSuccess('Post créé avec succès');
// Actualiser automatiquement la liste des posts
_refreshTrigger.value++;
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la création: ${e.toString()}');
}
}
}
}