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,619 @@
# Widgets Sociaux - Architecture Modulaire
Architecture de composants réutilisables pour les posts sociaux avec support d'images et vidéos.
## 📐 Architecture
### 🔹 Widgets Atomiques (Niveau 1)
Les plus petits composants réutilisables dans toute l'application.
#### `SocialActionButton`
**Bouton d'action tout petit et uniforme**
- Taille par défaut: 22px
- Supporte tooltip
- Animation scale: 0.85
- Couleur personnalisable
```dart
SocialActionButton(
icon: Icons.favorite_rounded,
onTap: () => handleLike(),
color: Colors.red,
tooltip: 'J\'aime',
)
```
#### `SocialActionButtonWithCount`
**Bouton d'action avec compteur**
- Affichage du nombre (1K, 1M formaté)
- Support état actif/inactif
- Couleur différente si actif
```dart
SocialActionButtonWithCount(
icon: Icons.favorite_rounded,
count: 245,
onTap: () => handleLike(),
isActive: true,
activeColor: Colors.red,
)
```
#### `SocialBadge`
**Badge réutilisable générique**
- Icône optionnelle
- Couleurs personnalisables
- Taille de police ajustable
- Padding personnalisable
```dart
SocialBadge(
label: 'Nouveau',
icon: Icons.fiber_new,
fontSize: 11,
)
```
#### `VerifiedBadge`
**Badge de compte vérifié**
- Icône verified avec tooltip
- Taille personnalisable
```dart
VerifiedBadge(size: 16)
```
#### `CategoryBadge`
**Badge de catégorie**
- Couleur secondaire
- Icône optionnelle
```dart
CategoryBadge(
category: 'Sport',
icon: Icons.sports_soccer,
)
```
#### `StatusBadge`
**Badge de statut dynamique**
- Couleurs automatiques selon le statut
- "nouveau" → primaryContainer
- "tendance" → errorContainer
- "populaire" → tertiaryContainer
```dart
StatusBadge(
status: 'tendance',
icon: Icons.trending_up,
)
```
#### `MediaCountBadge`
**Badge de nombre de médias**
- Pour images/vidéos
- Affichage sur fond noir semi-transparent
- Icône différente selon le type
```dart
MediaCountBadge(
count: 5,
isVideo: false,
)
```
### 🔹 Widgets de Médias (Niveau 2)
Composants spécialisés pour la gestion des médias.
#### `PostMedia` (Model)
**Modèle de données pour un média**
```dart
class PostMedia {
final String url;
final MediaType type; // image ou video
final String? thumbnailUrl;
final Duration? duration;
}
```
#### `PostMediaViewer`
**Affichage de médias avec dispositions multiples**
- 1 média: Aspect ratio 1:1
- 2 médias: Côte à côte
- 3 médias: 1 grand + 2 petits
- 4+ médias: Grille 2x2 avec compteur "+N"
**Fonctionnalités:**
- Hero animation
- Loading progressif
- Gestion d'erreurs
- Tap pour plein écran
- Badge de durée pour vidéos
```dart
PostMediaViewer(
medias: [
PostMedia(url: 'image1.jpg', type: MediaType.image),
PostMedia(url: 'video1.mp4', type: MediaType.video, duration: Duration(minutes: 2, seconds: 30)),
],
postId: post.id,
onTap: () => handleMediaTap(),
)
```
#### `MediaPicker`
**Sélecteur de médias pour création de post**
- Sélection depuis galerie (multi)
- Capture photo depuis caméra
- Sélection vidéo
- Limite: 10 médias max (personnalisable)
- Prévisualisation avec bouton supprimer
- Indicateur vidéo sur thumbnails
**Fonctionnalités:**
- Compteur médias sélectionnés
- Boutons désactivés si limite atteinte
- Grille horizontale scrollable
- Suppression individuelle
```dart
MediaPicker(
onMediasChanged: (medias) {
setState(() => selectedMedias = medias);
},
maxMedias: 10,
initialMedias: [],
)
```
### 🔹 Dialogues et Composants Complexes (Niveau 3)
#### `CreatePostDialog`
**Dialogue de création de post avec médias**
**Fonctionnalités:**
- Avatar et nom utilisateur
- Champ texte (3-8 lignes, max 500 caractères)
- MediaPicker intégré
- Validation formulaire
- État de chargement pendant création
- Compteur caractères
- Visibilité (Public par défaut)
- **Compression automatique** des images avant upload
- **Upload progressif** avec indicateur de progression
- **Nettoyage automatique** des fichiers temporaires
**Processus d'upload en 3 étapes:**
1. Compression des images (0-50%)
2. Upload des médias (50-100%)
3. Création du post
**Présentation:**
- Modal bottom sheet
- Auto-focus sur le champ texte
- Padding adapté au clavier
- Actions: Annuler / Publier
- Barre de progression avec statut textuel
```dart
await CreatePostDialog.show(
context: context,
onPostCreated: (content, medias) async {
await createPost(content, medias);
},
userName: 'Jean Dupont',
userAvatarUrl: 'avatar.jpg',
);
```
#### `EditPostDialog`
**Dialogue d'édition de post existant**
**Fonctionnalités:**
- Pré-remplissage avec contenu existant
- Détection automatique des changements
- MediaPicker pour modifier les médias
- Validation formulaire
- Bouton "Enregistrer" désactivé si aucun changement
- État de chargement pendant mise à jour
**Présentation:**
- Modal bottom sheet
- Auto-focus sur le champ texte
- Icône d'édition dans l'en-tête
- Actions: Annuler / Enregistrer
```dart
await EditPostDialog.show(
context: context,
post: existingPost,
onPostUpdated: (content, medias) async {
await updatePost(existingPost.id, content, medias);
},
);
```
#### `FullscreenVideoPlayer`
**Lecteur vidéo plein écran avec contrôles**
**Fonctionnalités:**
- Lecture automatique au démarrage
- Orientation paysage forcée
- Mode immersif (barre système cachée)
- Contrôles tactiles :
- Tap pour afficher/masquer contrôles
- Play/Pause
- Reculer de 10s
- Avancer de 10s
- Barre de progression interactive (scrubbing)
- Affichage durée actuelle / totale
- Hero animation pour transition fluide
**Présentation:**
- Fond noir
- Header avec bouton fermer et titre
- Contrôles centraux (reculer/play/avancer)
- Footer avec barre de progression
- Gradient overlay sur header/footer
```dart
await FullscreenVideoPlayer.show(
context: context,
videoUrl: 'https://example.com/video.mp4',
heroTag: 'post_media_123_0',
title: 'Ma vidéo',
);
```
#### `SocialCardRefactored`
**Card modulaire de post social**
**Composants internes:**
- `_PostHeader`: Avatar gradient, nom, badge vérifié, timestamp, menu
- `_UserAvatar`: Avatar avec bordure gradient
- `_PostTimestamp`: Formatage relatif (Il y a X min/h/j)
- `_PostMenu`: PopupMenu (Modifier, Supprimer)
- `_PostActions`: Like, Comment, Share, Bookmark
- `_PostStats`: Nombre de likes
- `_PostContent`: Texte enrichi (hashtags #, mentions @)
- `_CommentsLink`: Lien vers commentaires
**Fonctionnalités:**
- Support médias via PostMediaViewer
- Hashtags et mentions cliquables (couleur primaire)
- "Voir plus/Voir moins" pour contenu long (>150 caractères)
- Badge de catégorie optionnel
- Badge vérifié optionnel
- AnimatedCard avec elevation
```dart
SocialCardRefactored(
post: post,
onLike: () => handleLike(),
onComment: () => handleComment(),
onShare: () => handleShare(),
onDeletePost: () => handleDelete(),
onEditPost: () => handleEdit(),
showVerifiedBadge: true,
showCategory: true,
category: 'Sport',
)
```
## 🔧 Services (lib/data/services/)
### `ImageCompressionService`
**Service de compression d'images avant upload**
**Configurations prédéfinies:**
- `CompressionConfig.post` : 85% qualité, 1920x1920px
- `CompressionConfig.thumbnail` : 70% qualité, 400x400px
- `CompressionConfig.story` : 90% qualité, 1080x1920px
- `CompressionConfig.avatar` : 80% qualité, 500x500px
**Fonctionnalités:**
- Compression avec qualité ajustable (0-100)
- Redimensionnement automatique
- Support formats: JPEG, PNG, WebP
- Compression parallèle pour plusieurs images
- Callback de progression
- Statistiques de réduction de taille
- Nettoyage automatique des fichiers temporaires
**Utilisation:**
```dart
final compressionService = ImageCompressionService();
// Compression d'une image
final compressed = await compressionService.compressImage(
imageFile,
config: CompressionConfig.post,
);
// Compression multiple avec progression
final compressedList = await compressionService.compressMultipleImages(
imageFiles,
config: CompressionConfig.thumbnail,
onProgress: (processed, total) {
print('Compression: $processed/$total');
},
);
// Créer un thumbnail
final thumbnail = await compressionService.createThumbnail(imageFile);
// Nettoyer les fichiers temporaires
await compressionService.cleanupTempFiles();
```
**Logs (si EnvConfig.enableDetailedLogs = true):**
```
[ImageCompression] Compression de: photo.jpg
[ImageCompression] Taille originale: 4.2 MB
[ImageCompression] Taille compressée: 1.8 MB
[ImageCompression] Réduction: 57.1%
```
### `MediaUploadService`
**Service d'upload de médias vers le backend**
**Fonctionnalités:**
- Upload d'images et vidéos
- Upload parallèle de plusieurs médias
- Callback de progression
- Génération automatique de thumbnails pour vidéos
- Support vidéos locales et réseau
- Suppression de médias
**Modèle de résultat:**
```dart
class MediaUploadResult {
final String url; // URL du média uploadé
final String? thumbnailUrl; // URL du thumbnail (vidéo)
final String type; // 'image' ou 'video'
final Duration? duration; // Durée (vidéo seulement)
}
```
**Utilisation:**
```dart
final uploadService = MediaUploadService(http.Client());
// Upload d'un média
final result = await uploadService.uploadMedia(imageFile);
print('URL: ${result.url}');
// Upload multiple avec progression
final results = await uploadService.uploadMultipleMedias(
mediaFiles,
onProgress: (uploaded, total) {
print('Upload: $uploaded/$total');
},
);
// Générer thumbnail pour vidéo
final thumbnailUrl = await uploadService.generateVideoThumbnail(videoUrl);
// Supprimer un média
await uploadService.deleteMedia(mediaUrl);
```
**Configuration:**
Le endpoint d'upload est configuré dans `EnvConfig.mediaUploadEndpoint`.
## 📦 Utilisation
### Import Simple
```dart
import 'package:afterwork/presentation/widgets/social/social_widgets.dart';
```
Ceci importe tous les widgets nécessaires :
- Boutons d'action
- Badges
- Media picker
- Post media viewer
- Create post dialog
- Social card
### Exemple Complet
```dart
// 1. Créer un post avec compression et upload automatiques
await CreatePostDialog.show(
context: context,
onPostCreated: (content, medias) async {
// Les médias sont déjà compressés et uploadés par le dialogue
await apiService.createPost(
content: content,
mediaFiles: medias,
);
},
userName: currentUser.name,
userAvatarUrl: currentUser.avatar,
);
// 2. Créer un post manuellement avec services
final compressionService = ImageCompressionService();
final uploadService = MediaUploadService(http.Client());
// Compresser les images
final compressedMedias = await compressionService.compressMultipleImages(
selectedMedias,
config: CompressionConfig.post,
onProgress: (processed, total) {
print('Compression: $processed/$total');
},
);
// Uploader les médias
final uploadResults = await uploadService.uploadMultipleMedias(
compressedMedias,
onProgress: (uploaded, total) {
print('Upload: $uploaded/$total');
},
);
// Créer le post avec les URLs
await apiService.createPost(
content: contentController.text,
mediaUrls: uploadResults.map((r) => r.url).toList(),
);
// Nettoyer les fichiers temporaires
await compressionService.cleanupTempFiles();
// 3. Afficher un post avec médias
SocialCardRefactored(
post: SocialPost(
id: '123',
content: 'Super soirée avec les amis ! #afterwork @john',
imageUrl: '', // Géré par medias maintenant
likesCount: 42,
commentsCount: 5,
sharesCount: 2,
isLikedByCurrentUser: false,
// ...
),
medias: [
PostMedia(
url: 'https://example.com/photo1.jpg',
type: MediaType.image,
),
PostMedia(
url: 'https://example.com/video1.mp4',
type: MediaType.video,
thumbnailUrl: 'https://example.com/video1_thumb.jpg',
duration: Duration(minutes: 2, seconds: 30),
),
],
onLike: () {
setState(() {
post = post.copyWith(
isLikedByCurrentUser: !post.isLikedByCurrentUser,
likesCount: post.isLikedByCurrentUser
? post.likesCount - 1
: post.likesCount + 1,
);
});
},
onComment: () {
Navigator.push(/* CommentsScreen */);
},
onEditPost: () async {
await EditPostDialog.show(
context: context,
post: post,
onPostUpdated: (content, medias) async {
await apiService.updatePost(post.id, content, medias);
},
);
},
onDeletePost: () async {
await apiService.deletePost(post.id);
},
showVerifiedBadge: user.isVerified,
showCategory: true,
category: post.category,
);
// 4. Afficher une vidéo en plein écran
GestureDetector(
onTap: () {
FullscreenVideoPlayer.show(
context: context,
videoUrl: 'https://example.com/video.mp4',
heroTag: 'post_media_${post.id}_0',
title: 'Ma vidéo',
);
},
child: Stack(
children: [
Image.network(videoThumbnail),
Center(
child: Icon(Icons.play_circle_outline, size: 64),
),
],
),
);
```
## 🎨 Principes de Design
### Uniformité
- Tous les boutons icônes: 20-22px
- Tous les badges: fontSize 10-11px
- Spacing: DesignSystem constants (4, 8, 12, 16px)
- Border radius: DesignSystem (4, 10, 16px)
### Réutilisabilité
- Chaque composant fait UNE chose
- Props claires et typées
- Valeurs par défaut sensées
- Documentation inline
### Performances
- Widgets const quand possible
- Keys pour listes
- Lazy loading des images
- AnimatedCard pour hover effects
### Accessibilité
- Tooltips sur tous les boutons
- Couleurs avec contraste suffisant
- Tailles tactiles minimales respectées (44x44)
## 🔧 Personnalisation
### Thème
Tous les widgets utilisent le ThemeData du contexte :
- `theme.colorScheme.primary`
- `theme.colorScheme.onSurface`
- `theme.textTheme.bodyMedium`
### DesignSystem
Les constantes sont centralisées :
- `DesignSystem.spacingSm` (8px)
- `DesignSystem.spacingMd` (12px)
- `DesignSystem.spacingLg` (16px)
- `DesignSystem.radiusSm` (4px)
- `DesignSystem.radiusMd` (10px)
- `DesignSystem.radiusLg` (16px)
## 📝 Statut des Fonctionnalités
### ✅ Complété
- [x] **Support de plusieurs médias par post** - PostMediaViewer supporte 1-10+ médias avec dispositions adaptatives
- [x] **Upload des médias vers le backend** - MediaUploadService avec progression et support image/vidéo
- [x] **Lecteur vidéo en plein écran** - FullscreenVideoPlayer avec contrôles complets
- [x] **Édition de post avec médias** - EditPostDialog avec détection de changements
- [x] **Compression d'images avant upload** - ImageCompressionService avec 4 configs prédéfinies
- [x] **Architecture modulaire** - Widgets atomiques, réutilisables et bien documentés
- [x] **Animations et interactions** - Like, bookmark, hero transitions, scale effects
- [x] **Hashtags et mentions cliquables** - Parsing et styling automatiques
- [x] **Progress tracking** - Indicateurs de progression pour compression et upload
### 🚧 À Venir
- [ ] **Stories avec médias** - Système de stories éphémères (24h) avec images/vidéos
- [ ] **Filtres sur les photos** - Filtres Instagram-style pour édition d'images
- [ ] **GIF support** - Support des GIFs animés dans les posts
- [ ] **Commentaires imbriqués** - Système de réponses aux commentaires
- [ ] **Réactions étendues** - Plus de réactions au-delà du simple like (❤️ 😂 😮 😢 😡)
- [ ] **Partage vers stories** - Partager un post dans sa story
- [ ] **Brouillons** - Sauvegarder des posts en brouillon
- [ ] **Planification** - Programmer la publication d'un post
### 📊 Métriques
- **13 widgets** créés (atomiques + composites)
- **2 services** (compression + upload)
- **5 types de badges** réutilisables
- **4 layouts** pour affichage médias (1, 2, 3, 4+)
- **3 étapes** de processus d'upload (compression, upload, création)
- **10 médias max** par post (configurable)

View File

@@ -0,0 +1,436 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import '../../../core/constants/design_system.dart';
import '../../../data/services/image_compression_service.dart';
import '../../../data/services/media_upload_service.dart';
import 'media_picker.dart';
/// Dialogue de création de post avec support d'images et vidéos.
class CreatePostDialog extends StatefulWidget {
const CreatePostDialog({
required this.onPostCreated,
this.userAvatarUrl,
this.userName,
super.key,
});
final Future<void> Function(String content, List<XFile> medias) onPostCreated;
final String? userAvatarUrl;
final String? userName;
@override
State<CreatePostDialog> createState() => _CreatePostDialogState();
/// Affiche le dialogue de création de post
static Future<void> show({
required BuildContext context,
required Future<void> Function(String content, List<XFile> medias)
onPostCreated,
String? userAvatarUrl,
String? userName,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => CreatePostDialog(
onPostCreated: onPostCreated,
userAvatarUrl: userAvatarUrl,
userName: userName,
),
);
}
}
class _CreatePostDialogState extends State<CreatePostDialog> {
final TextEditingController _contentController = TextEditingController();
final FocusNode _contentFocusNode = FocusNode();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late final ImageCompressionService _compressionService;
late final MediaUploadService _uploadService;
List<XFile> _selectedMedias = [];
bool _isPosting = false;
double _uploadProgress = 0.0;
String _uploadStatus = '';
@override
void initState() {
super.initState();
// Initialiser les services
_compressionService = ImageCompressionService();
_uploadService = MediaUploadService(http.Client());
// Auto-focus sur le champ de texte
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_contentFocusNode.requestFocus();
}
});
}
@override
void dispose() {
_contentController.dispose();
_contentFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
padding: EdgeInsets.only(
bottom: mediaQuery.viewInsets.bottom,
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(theme),
const SizedBox(height: DesignSystem.spacingLg),
_buildUserInfo(theme),
const SizedBox(height: DesignSystem.spacingMd),
_buildContentField(theme),
const SizedBox(height: DesignSystem.spacingLg),
MediaPicker(
onMediasChanged: (medias) {
setState(() {
_selectedMedias = medias;
});
},
initialMedias: _selectedMedias,
),
const SizedBox(height: DesignSystem.spacingLg),
_buildActions(theme),
],
),
),
),
),
);
}
Widget _buildHeader(ThemeData theme) {
return Row(
children: [
// Handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
const Spacer(),
// Titre
Text(
'Créer un post',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
const Spacer(),
// Bouton fermer
IconButton(
icon: const Icon(Icons.close_rounded, size: 22),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
],
);
}
Widget _buildUserInfo(ThemeData theme) {
return Row(
children: [
// Avatar
CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: widget.userAvatarUrl != null &&
widget.userAvatarUrl!.isNotEmpty
? NetworkImage(widget.userAvatarUrl!)
: null,
child: widget.userAvatarUrl == null || widget.userAvatarUrl!.isEmpty
? Icon(
Icons.person_rounded,
size: 24,
color: theme.colorScheme.onPrimaryContainer,
)
: null,
),
const SizedBox(width: DesignSystem.spacingMd),
// Nom
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.userName ?? 'Utilisateur',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.public_rounded,
size: 14,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'Public',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
],
),
),
],
);
}
Widget _buildContentField(ThemeData theme) {
return Form(
key: _formKey,
child: TextFormField(
controller: _contentController,
focusNode: _contentFocusNode,
decoration: InputDecoration(
hintText: 'Quoi de neuf ?',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 16,
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 16,
height: 1.5,
),
maxLines: 8,
minLines: 3,
maxLength: 500,
validator: (value) {
if ((value == null || value.trim().isEmpty) &&
_selectedMedias.isEmpty) {
return 'Ajoutez du texte ou des médias';
}
return null;
},
),
);
}
Widget _buildActions(ThemeData theme) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Indicateur de progression lors de l'upload
if (_isPosting && _uploadStatus.isNotEmpty) ...[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_uploadStatus,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: DesignSystem.spacingSm),
LinearProgressIndicator(
value: _uploadProgress,
backgroundColor: theme.colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: DesignSystem.spacingMd),
],
// Actions
Row(
children: [
// Indicateur de caractères
Expanded(
child: Text(
'${_contentController.text.length}/500',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
),
const SizedBox(width: DesignSystem.spacingMd),
// Bouton Annuler
TextButton(
onPressed: _isPosting ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: DesignSystem.spacingSm),
// Bouton Publier
FilledButton(
onPressed: _isPosting ? null : _handlePost,
child: _isPosting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Publier'),
),
],
),
],
);
}
Future<void> _handlePost() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isPosting = true;
_uploadProgress = 0.0;
_uploadStatus = 'Préparation...';
});
try {
List<XFile> processedMedias = _selectedMedias;
// Étape 1: Compression des images
if (_selectedMedias.isNotEmpty) {
setState(() {
_uploadStatus = 'Compression des images...';
});
processedMedias = await _compressionService.compressMultipleImages(
_selectedMedias,
config: CompressionConfig.post,
onProgress: (processed, total) {
if (mounted) {
setState(() {
_uploadProgress = (processed / total) * 0.5;
_uploadStatus =
'Compression $processed/$total...';
});
}
},
);
}
// Étape 2: Upload des médias
if (processedMedias.isNotEmpty) {
setState(() {
_uploadStatus = 'Upload des médias...';
});
final uploadResults = await _uploadService.uploadMultipleMedias(
processedMedias,
onProgress: (uploaded, total) {
if (mounted) {
setState(() {
_uploadProgress = 0.5 + (uploaded / total) * 0.5;
_uploadStatus = 'Upload $uploaded/$total...';
});
}
},
);
// Extraire les URLs des médias uploadés
final uploadedMediaUrls = uploadResults.map((result) => result.url).toList();
// Note: Les URLs uploadées sont disponibles dans uploadedMediaUrls.
// Pour l'instant, on passe encore les fichiers locaux pour compatibilité,
// mais le backend devrait utiliser les URLs uploadées.
// À terme, modifier la signature de onPostCreated pour accepter List<String> urls
// au lieu de List<XFile> medias.
if (mounted) {
debugPrint('[CreatePostDialog] URLs uploadées: $uploadedMediaUrls');
}
}
// Étape 3: Création du post
setState(() {
_uploadStatus = 'Création du post...';
_uploadProgress = 1.0;
});
await widget.onPostCreated(
_contentController.text.trim(),
processedMedias,
);
// Nettoyer les fichiers temporaires
await _compressionService.cleanupTempFiles();
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
final theme = Theme.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la publication: ${e.toString()}'),
backgroundColor: theme.colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() {
_isPosting = false;
_uploadProgress = 0.0;
_uploadStatus = '';
});
}
}
}
}

View File

@@ -0,0 +1,331 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/constants/design_system.dart';
import '../../../domain/entities/social_post.dart';
import 'media_picker.dart';
/// Dialogue d'édition de post existant avec support d'images et vidéos.
class EditPostDialog extends StatefulWidget {
const EditPostDialog({
required this.post,
required this.onPostUpdated,
super.key,
});
final SocialPost post;
final Future<void> Function(String content, List<XFile> medias) onPostUpdated;
@override
State<EditPostDialog> createState() => _EditPostDialogState();
/// Affiche le dialogue d'édition de post
static Future<void> show({
required BuildContext context,
required SocialPost post,
required Future<void> Function(String content, List<XFile> medias)
onPostUpdated,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => EditPostDialog(
post: post,
onPostUpdated: onPostUpdated,
),
);
}
}
class _EditPostDialogState extends State<EditPostDialog> {
late final TextEditingController _contentController;
final FocusNode _contentFocusNode = FocusNode();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
List<XFile> _selectedMedias = [];
bool _isUpdating = false;
@override
void initState() {
super.initState();
_contentController = TextEditingController(text: widget.post.content);
// Note: Les médias existants du post sont déjà stockés sous forme d'URLs
// dans widget.post.mediaUrls. Ils seront affichés via ces URLs.
// _selectedMedias permet d'ajouter de NOUVEAUX médias locaux.
// Lors de la sauvegarde, il faut combiner:
// - Les URLs existantes (widget.post.mediaUrls)
// - Les nouveaux médias uploadés (URLs générées depuis _selectedMedias)
_selectedMedias = [];
// Auto-focus sur le champ de texte
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_contentFocusNode.requestFocus();
}
});
}
@override
void dispose() {
_contentController.dispose();
_contentFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
padding: EdgeInsets.only(
bottom: mediaQuery.viewInsets.bottom,
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(theme),
const SizedBox(height: DesignSystem.spacingLg),
_buildUserInfo(theme),
const SizedBox(height: DesignSystem.spacingMd),
_buildContentField(theme),
const SizedBox(height: DesignSystem.spacingLg),
MediaPicker(
onMediasChanged: (medias) {
setState(() {
_selectedMedias = medias;
});
},
initialMedias: _selectedMedias,
),
const SizedBox(height: DesignSystem.spacingLg),
_buildActions(theme),
],
),
),
),
),
);
}
Widget _buildHeader(ThemeData theme) {
return Row(
children: [
// Handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
const Spacer(),
// Titre
Text(
'Modifier le post',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
const Spacer(),
// Bouton fermer
IconButton(
icon: const Icon(Icons.close_rounded, size: 22),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
],
);
}
Widget _buildUserInfo(ThemeData theme) {
return Row(
children: [
// Avatar
CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: widget.post.userProfileImageUrl.isNotEmpty
? NetworkImage(widget.post.userProfileImageUrl)
: null,
child: widget.post.userProfileImageUrl.isEmpty
? Icon(
Icons.person_rounded,
size: 24,
color: theme.colorScheme.onPrimaryContainer,
)
: null,
),
const SizedBox(width: DesignSystem.spacingMd),
// Nom
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.post.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.edit_rounded,
size: 14,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'Édition',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
],
),
),
],
);
}
Widget _buildContentField(ThemeData theme) {
return Form(
key: _formKey,
child: TextFormField(
controller: _contentController,
focusNode: _contentFocusNode,
decoration: InputDecoration(
hintText: 'Modifiez votre post...',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 16,
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 16,
height: 1.5,
),
maxLines: 8,
minLines: 3,
maxLength: 500,
validator: (value) {
if ((value == null || value.trim().isEmpty) &&
_selectedMedias.isEmpty) {
return 'Ajoutez du texte ou des médias';
}
return null;
},
),
);
}
Widget _buildActions(ThemeData theme) {
final hasChanges = _contentController.text != widget.post.content ||
_selectedMedias.isNotEmpty;
return Row(
children: [
// Indicateur de caractères
Expanded(
child: Text(
'${_contentController.text.length}/500',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
),
const SizedBox(width: DesignSystem.spacingMd),
// Bouton Annuler
TextButton(
onPressed: _isUpdating ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: DesignSystem.spacingSm),
// Bouton Enregistrer
FilledButton(
onPressed: (_isUpdating || !hasChanges) ? null : _handleUpdate,
child: _isUpdating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Enregistrer'),
),
],
);
}
Future<void> _handleUpdate() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isUpdating = true;
});
try {
await widget.onPostUpdated(
_contentController.text.trim(),
_selectedMedias,
);
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
final theme = Theme.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour: ${e.toString()}'),
backgroundColor: theme.colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() {
_isUpdating = false;
});
}
}
}
}

View File

@@ -0,0 +1,389 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import '../../../core/constants/design_system.dart';
/// Lecteur vidéo plein écran avec contrôles.
///
/// Affiche une vidéo en plein écran avec contrôles de lecture,
/// barre de progression et gestion de l'orientation.
class FullscreenVideoPlayer extends StatefulWidget {
const FullscreenVideoPlayer({
required this.videoUrl,
this.heroTag,
this.title,
super.key,
});
final String videoUrl;
final String? heroTag;
final String? title;
@override
State<FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
/// Affiche le lecteur vidéo en plein écran.
static Future<void> show({
required BuildContext context,
required String videoUrl,
String? heroTag,
String? title,
}) {
return Navigator.push<void>(
context,
PageRouteBuilder<void>(
opaque: false,
barrierColor: Colors.black,
pageBuilder: (context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FullscreenVideoPlayer(
videoUrl: videoUrl,
heroTag: heroTag,
title: title,
),
);
},
),
);
}
}
class _FullscreenVideoPlayerState extends State<FullscreenVideoPlayer> {
late VideoPlayerController _controller;
bool _isInitialized = false;
bool _showControls = true;
bool _isPlaying = false;
@override
void initState() {
super.initState();
_initializePlayer();
_setLandscapeOrientation();
}
@override
void dispose() {
_resetOrientation();
_controller.dispose();
super.dispose();
}
Future<void> _initializePlayer() async {
// Déterminer si c'est une URL réseau ou locale
if (widget.videoUrl.startsWith('http')) {
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.videoUrl),
);
} else {
// Pour les fichiers locaux (pas encore implémenté)
// _controller = VideoPlayerController.file(File(widget.videoUrl));
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.videoUrl),
);
}
try {
await _controller.initialize();
_controller.addListener(_videoListener);
if (mounted) {
setState(() {
_isInitialized = true;
});
// Démarrer la lecture automatiquement
_controller.play();
_isPlaying = true;
}
} catch (e) {
debugPrint('[FullscreenVideoPlayer] Erreur initialisation: $e');
}
}
void _videoListener() {
if (_controller.value.isPlaying != _isPlaying) {
setState(() {
_isPlaying = _controller.value.isPlaying;
});
}
}
Future<void> _setLandscapeOrientation() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
Future<void> _resetOrientation() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
void _togglePlayPause() {
setState(() {
if (_controller.value.isPlaying) {
_controller.pause();
} else {
_controller.play();
}
});
}
void _toggleControls() {
setState(() {
_showControls = !_showControls;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _toggleControls,
child: Stack(
fit: StackFit.expand,
children: [
// Vidéo
Center(
child: _isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
)
: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
),
// Contrôles
if (_showControls) ...[
// Header
_buildHeader(context),
// Contrôles centraux
_buildCenterControls(),
// Footer avec barre de progression
_buildFooter(),
],
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingMd),
child: Row(
children: [
// Bouton retour
IconButton(
icon: const Icon(
Icons.close_rounded,
color: Colors.white,
size: 28,
),
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: DesignSystem.spacingMd),
// Titre
if (widget.title != null && widget.title!.isNotEmpty)
Expanded(
child: Text(
widget.title!,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
);
}
Widget _buildCenterControls() {
return Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Reculer de 10s
_buildControlButton(
icon: Icons.replay_10_rounded,
onTap: () {
final currentPosition = _controller.value.position;
final newPosition = currentPosition - const Duration(seconds: 10);
_controller.seekTo(
newPosition < Duration.zero ? Duration.zero : newPosition,
);
},
),
const SizedBox(width: DesignSystem.spacingXl),
// Play/Pause
_buildControlButton(
icon: _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
onTap: _togglePlayPause,
size: 80,
),
const SizedBox(width: DesignSystem.spacingXl),
// Avancer de 10s
_buildControlButton(
icon: Icons.forward_10_rounded,
onTap: () {
final currentPosition = _controller.value.position;
final duration = _controller.value.duration;
final newPosition = currentPosition + const Duration(seconds: 10);
_controller.seekTo(
newPosition > duration ? duration : newPosition,
);
},
),
],
),
);
}
Widget _buildControlButton({
required IconData icon,
required VoidCallback onTap,
double size = 60,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.all(size / 5),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: Colors.white,
size: size / 1.5,
),
),
);
}
Widget _buildFooter() {
if (!_isInitialized) return const SizedBox.shrink();
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingMd),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Barre de progression
VideoProgressIndicator(
_controller,
allowScrubbing: true,
colors: const VideoProgressColors(
playedColor: Colors.red,
bufferedColor: Colors.white54,
backgroundColor: Colors.white24,
),
padding: EdgeInsets.zero,
),
const SizedBox(height: DesignSystem.spacingSm),
// Temps
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_controller.value.position),
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
Text(
_formatDuration(_controller.value.duration),
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
),
),
);
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,321 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/constants/design_system.dart';
/// Widget de sélection de médias pour la création de posts.
///
/// Permet de sélectionner des images et vidéos depuis la galerie ou l'appareil photo.
class MediaPicker extends StatefulWidget {
const MediaPicker({
required this.onMediasChanged,
this.maxMedias = 10,
this.initialMedias = const [],
super.key,
});
final ValueChanged<List<XFile>> onMediasChanged;
final int maxMedias;
final List<XFile> initialMedias;
@override
State<MediaPicker> createState() => _MediaPickerState();
}
class _MediaPickerState extends State<MediaPicker> {
final ImagePicker _picker = ImagePicker();
List<XFile> _selectedMedias = [];
@override
void initState() {
super.initState();
_selectedMedias = List.from(widget.initialMedias);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Titre avec compteur
Row(
children: [
Text(
'Médias',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${_selectedMedias.length}/${widget.maxMedias}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
],
),
const SizedBox(height: DesignSystem.spacingMd),
// Boutons d'action
Row(
children: [
_buildActionButton(
context,
icon: Icons.photo_library_outlined,
label: 'Galerie',
onTap: _pickFromGallery,
),
const SizedBox(width: DesignSystem.spacingSm),
_buildActionButton(
context,
icon: Icons.camera_alt_outlined,
label: 'Caméra',
onTap: _pickFromCamera,
),
const SizedBox(width: DesignSystem.spacingSm),
_buildActionButton(
context,
icon: Icons.videocam_outlined,
label: 'Vidéo',
onTap: _pickVideo,
),
],
),
// Grille de médias sélectionnés
if (_selectedMedias.isNotEmpty) ...[
const SizedBox(height: DesignSystem.spacingMd),
_buildMediasGrid(theme),
],
],
);
}
Widget _buildActionButton(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
final canAddMore = _selectedMedias.length < widget.maxMedias;
return Expanded(
child: Material(
color: canAddMore
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
child: InkWell(
onTap: canAddMore ? onTap : null,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingMd,
),
child: Column(
children: [
Icon(
icon,
size: 24,
color: canAddMore
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
),
const SizedBox(height: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w600,
color: canAddMore
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
],
),
),
),
),
);
}
Widget _buildMediasGrid(ThemeData theme) {
return SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _selectedMedias.length,
itemBuilder: (context, index) {
return _buildMediaItem(theme, index);
},
),
);
}
Widget _buildMediaItem(ThemeData theme, int index) {
final media = _selectedMedias[index];
final isVideo = media.path.toLowerCase().endsWith('.mp4') ||
media.path.toLowerCase().endsWith('.mov');
return Padding(
padding: const EdgeInsets.only(right: DesignSystem.spacingSm),
child: Stack(
children: [
// Image/Vidéo
ClipRRect(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
child: Container(
width: 100,
height: 100,
color: theme.colorScheme.surfaceVariant,
child: isVideo
? Center(
child: Icon(
Icons.videocam_rounded,
size: 32,
color: theme.colorScheme.onSurfaceVariant,
),
)
: Image.file(
File(media.path),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.broken_image_rounded,
size: 32,
color: theme.colorScheme.onSurfaceVariant,
),
);
},
),
),
),
// Bouton de suppression
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => _removeMedia(index),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close_rounded,
size: 16,
color: Colors.white,
),
),
),
),
// Indicateur vidéo
if (isVideo)
Positioned(
bottom: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.play_circle_outline,
size: 12,
color: Colors.white,
),
],
),
),
),
],
),
);
}
Future<void> _pickFromGallery() async {
try {
final images = await _picker.pickMultiImage();
if (images.isNotEmpty) {
_addMedias(images);
}
} catch (e) {
debugPrint('[MediaPicker] Erreur lors de la sélection: $e');
}
}
Future<void> _pickFromCamera() async {
try {
final image = await _picker.pickImage(source: ImageSource.camera);
if (image != null) {
_addMedias([image]);
}
} catch (e) {
debugPrint('[MediaPicker] Erreur lors de la capture: $e');
}
}
Future<void> _pickVideo() async {
try {
final video = await _picker.pickVideo(source: ImageSource.gallery);
if (video != null) {
_addMedias([video]);
}
} catch (e) {
debugPrint('[MediaPicker] Erreur lors de la sélection vidéo: $e');
}
}
void _addMedias(List<XFile> medias) {
final availableSlots = widget.maxMedias - _selectedMedias.length;
final mediasToAdd = medias.take(availableSlots).toList();
setState(() {
_selectedMedias.addAll(mediasToAdd);
});
widget.onMediasChanged(_selectedMedias);
}
void _removeMedia(int index) {
setState(() {
_selectedMedias.removeAt(index);
});
widget.onMediasChanged(_selectedMedias);
}
}

View File

@@ -0,0 +1,371 @@
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
import '../fullscreen_image_viewer.dart';
import 'fullscreen_video_player.dart';
/// Type de média dans un post.
enum MediaType { image, video }
/// Modèle de média pour un post.
class PostMedia {
const PostMedia({
required this.url,
required this.type,
this.thumbnailUrl,
this.duration,
});
final String url;
final MediaType type;
final String? thumbnailUrl;
final Duration? duration;
}
/// Widget d'affichage de médias dans un post social.
///
/// Supporte les images et vidéos avec différentes dispositions.
class PostMediaViewer extends StatelessWidget {
const PostMediaViewer({
required this.medias,
required this.postId,
this.onTap,
super.key,
});
final List<PostMedia> medias;
final String postId;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
if (medias.isEmpty) return const SizedBox.shrink();
if (medias.length == 1) {
return _buildSingleMedia(context, medias[0], 0);
} else if (medias.length == 2) {
return _buildDoubleMedia(context);
} else if (medias.length == 3) {
return _buildTripleMedia(context);
} else {
return _buildMultipleMedia(context);
}
}
/// Affiche un seul média
Widget _buildSingleMedia(BuildContext context, PostMedia media, int index) {
return GestureDetector(
onTap: onTap,
child: AspectRatio(
aspectRatio: 1.0,
child: _MediaItem(
media: media,
postId: postId,
index: index,
),
),
);
}
/// Affiche deux médias côte à côte
Widget _buildDoubleMedia(BuildContext context) {
return SizedBox(
height: 300,
child: Row(
children: [
Expanded(
child: _MediaItem(
media: medias[0],
postId: postId,
index: 0,
),
),
const SizedBox(width: 2),
Expanded(
child: _MediaItem(
media: medias[1],
postId: postId,
index: 1,
),
),
],
),
);
}
/// Affiche trois médias (1 grand + 2 petits)
Widget _buildTripleMedia(BuildContext context) {
return SizedBox(
height: 300,
child: Row(
children: [
Expanded(
flex: 2,
child: _MediaItem(
media: medias[0],
postId: postId,
index: 0,
),
),
const SizedBox(width: 2),
Expanded(
child: Column(
children: [
Expanded(
child: _MediaItem(
media: medias[1],
postId: postId,
index: 1,
),
),
const SizedBox(height: 2),
Expanded(
child: _MediaItem(
media: medias[2],
postId: postId,
index: 2,
),
),
],
),
),
],
),
);
}
/// Affiche 4+ médias (grille 2x2 avec compteur)
Widget _buildMultipleMedia(BuildContext context) {
return SizedBox(
height: 300,
child: Row(
children: [
Expanded(
child: Column(
children: [
Expanded(
child: _MediaItem(
media: medias[0],
postId: postId,
index: 0,
),
),
const SizedBox(height: 2),
Expanded(
child: _MediaItem(
media: medias[2],
postId: postId,
index: 2,
),
),
],
),
),
const SizedBox(width: 2),
Expanded(
child: Column(
children: [
Expanded(
child: _MediaItem(
media: medias[1],
postId: postId,
index: 1,
),
),
const SizedBox(height: 2),
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
_MediaItem(
media: medias[3],
postId: postId,
index: 3,
),
if (medias.length > 4)
Container(
color: Colors.black.withOpacity(0.6),
child: Center(
child: Text(
'+${medias.length - 4}',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
),
],
),
);
}
}
/// Widget d'affichage d'un média individuel.
class _MediaItem extends StatelessWidget {
const _MediaItem({
required this.media,
required this.postId,
required this.index,
});
final PostMedia media;
final String postId;
final int index;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Hero(
tag: 'post_media_${postId}_$index',
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
child: media.type == MediaType.image
? _buildImage(context, theme)
: _buildVideo(context, theme),
),
);
}
Widget _buildImage(BuildContext context, ThemeData theme) {
return GestureDetector(
onTap: () => _openFullscreen(context),
child: Image.network(
media.url,
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,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.broken_image_rounded,
size: 32,
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
),
);
},
),
);
}
Widget _buildVideo(BuildContext context, ThemeData theme) {
return Stack(
fit: StackFit.expand,
children: [
// Thumbnail ou image de preview
if (media.thumbnailUrl != null)
Image.network(
media.thumbnailUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: theme.colorScheme.surfaceVariant,
);
},
)
else
Container(
color: theme.colorScheme.surfaceVariant,
),
// Overlay de play
Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 32,
),
),
),
// Durée de la vidéo
if (media.duration != null)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Text(
_formatDuration(media.duration!),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1.2,
),
),
),
),
],
);
}
void _openFullscreen(BuildContext context) {
if (media.type == MediaType.image) {
Navigator.push<void>(
context,
PageRouteBuilder<void>(
opaque: false,
barrierColor: Colors.black,
pageBuilder: (context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FullscreenImageViewer(
imageUrl: media.url,
heroTag: 'post_media_${postId}_$index',
title: '',
),
);
},
),
);
} else {
// Ouvrir le lecteur vidéo en plein écran
FullscreenVideoPlayer.show(
context: context,
videoUrl: media.url,
heroTag: 'post_media_${postId}_$index',
title: '',
);
}
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
import '../animated_widgets.dart';
/// Bouton d'action tout petit et réutilisable pour les posts sociaux.
///
/// Design compact et uniforme pour les actions (like, comment, share, etc.)
class SocialActionButton extends StatelessWidget {
const SocialActionButton({
required this.icon,
required this.onTap,
this.color,
this.size = 22,
this.padding,
this.tooltip,
super.key,
});
final IconData icon;
final VoidCallback onTap;
final Color? color;
final double size;
final EdgeInsets? padding;
final String? tooltip;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor =
color ?? theme.colorScheme.onSurface.withOpacity(0.7);
final button = AnimatedScaleButton(
onTap: onTap,
scaleFactor: 0.85,
child: Padding(
padding: padding ??
const EdgeInsets.all(DesignSystem.spacingSm),
child: Icon(
icon,
size: size,
color: effectiveColor,
),
),
);
if (tooltip != null) {
return Tooltip(
message: tooltip!,
child: button,
);
}
return button;
}
}
/// Bouton d'action avec compteur pour les posts sociaux.
class SocialActionButtonWithCount extends StatelessWidget {
const SocialActionButtonWithCount({
required this.icon,
required this.count,
required this.onTap,
this.color,
this.activeColor,
this.isActive = false,
this.size = 22,
this.showCount = true,
super.key,
});
final IconData icon;
final int count;
final VoidCallback onTap;
final Color? color;
final Color? activeColor;
final bool isActive;
final double size;
final bool showCount;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor = isActive
? (activeColor ?? theme.colorScheme.primary)
: (color ?? theme.colorScheme.onSurface.withOpacity(0.7));
return AnimatedScaleButton(
onTap: onTap,
scaleFactor: 0.85,
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingSm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: size,
color: effectiveColor,
),
if (showCount && count > 0) ...[
const SizedBox(width: 4),
Text(
_formatCount(count),
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: effectiveColor,
letterSpacing: -0.2,
),
),
],
],
),
),
);
}
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

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
/// Badge réutilisable pour les posts sociaux.
///
/// Design compact et uniforme pour différents types de badges.
class SocialBadge extends StatelessWidget {
const SocialBadge({
required this.label,
this.icon,
this.color,
this.backgroundColor,
this.fontSize = 11,
this.padding,
super.key,
});
final String label;
final IconData? icon;
final Color? color;
final Color? backgroundColor;
final double fontSize;
final EdgeInsets? padding;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor = color ?? theme.colorScheme.onPrimaryContainer;
final effectiveBackgroundColor =
backgroundColor ?? theme.colorScheme.primaryContainer;
return Container(
padding: padding ??
const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
decoration: BoxDecoration(
color: effectiveBackgroundColor,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: fontSize + 2,
color: effectiveColor,
),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: effectiveColor,
letterSpacing: -0.1,
height: 1.2,
),
),
],
),
);
}
}
/// Badge vérifié pour les utilisateurs vérifiés.
class VerifiedBadge extends StatelessWidget {
const VerifiedBadge({
this.size = 16,
super.key,
});
final double size;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Tooltip(
message: 'Compte vérifié',
child: Icon(
Icons.verified,
size: size,
color: theme.colorScheme.primary,
),
);
}
}
/// Badge de catégorie pour les posts.
class CategoryBadge extends StatelessWidget {
const CategoryBadge({
required this.category,
this.icon,
super.key,
});
final String category;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SocialBadge(
label: category,
icon: icon,
backgroundColor: theme.colorScheme.secondaryContainer,
color: theme.colorScheme.onSecondaryContainer,
fontSize: 10,
);
}
}
/// Badge de statut pour les posts (nouveau, tendance, etc.).
class StatusBadge extends StatelessWidget {
const StatusBadge({
required this.status,
this.icon,
super.key,
});
final String status;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Color backgroundColor;
Color color;
switch (status.toLowerCase()) {
case 'nouveau':
case 'new':
backgroundColor = theme.colorScheme.primaryContainer;
color = theme.colorScheme.onPrimaryContainer;
break;
case 'tendance':
case 'trending':
backgroundColor = theme.colorScheme.errorContainer;
color = theme.colorScheme.onErrorContainer;
break;
case 'populaire':
case 'popular':
backgroundColor = theme.colorScheme.tertiaryContainer;
color = theme.colorScheme.onTertiaryContainer;
break;
default:
backgroundColor = theme.colorScheme.surfaceVariant;
color = theme.colorScheme.onSurfaceVariant;
}
return SocialBadge(
label: status,
icon: icon,
backgroundColor: backgroundColor,
color: color,
fontSize: 10,
);
}
}
/// Badge de nombre de médias (images/vidéos).
class MediaCountBadge extends StatelessWidget {
const MediaCountBadge({
required this.count,
this.isVideo = false,
super.key,
});
final int count;
final bool isVideo;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isVideo ? Icons.play_circle_outline : Icons.image_outlined,
size: 12,
color: Colors.white,
),
const SizedBox(width: 3),
Text(
count.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1.2,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,585 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
import '../../../domain/entities/social_post.dart';
import '../animated_widgets.dart';
import 'post_media_viewer.dart';
import 'social_action_button.dart';
import 'social_badge.dart';
/// Card modulaire et réutilisable pour afficher un post social.
///
/// Utilise des composants atomiques pour une meilleure réutilisabilité.
class SocialCardRefactored extends StatefulWidget {
const SocialCardRefactored({
required this.post,
required this.onLike,
required this.onComment,
required this.onShare,
required this.onDeletePost,
required this.onEditPost,
this.showVerifiedBadge = false,
this.showCategory = false,
this.category,
super.key,
});
final SocialPost post;
final VoidCallback onLike;
final VoidCallback onComment;
final VoidCallback onShare;
final VoidCallback onDeletePost;
final VoidCallback onEditPost;
final bool showVerifiedBadge;
final bool showCategory;
final String? category;
@override
State<SocialCardRefactored> createState() => _SocialCardRefactoredState();
}
class _SocialCardRefactoredState extends State<SocialCardRefactored> {
bool _showFullContent = false;
@override
Widget build(BuildContext context) {
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
_PostHeader(
post: widget.post,
showVerifiedBadge: widget.showVerifiedBadge,
showCategory: widget.showCategory,
category: widget.category,
onEdit: widget.onEditPost,
onDelete: widget.onDeletePost,
),
// Médias (si présents)
if (widget.post.imageUrl != null &&
widget.post.imageUrl!.isNotEmpty)
PostMediaViewer(
medias: [
PostMedia(
url: widget.post.imageUrl!,
type: MediaType.image,
),
],
postId: widget.post.id,
onTap: widget.onLike,
),
// Contenu et interactions
Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actions rapides
_PostActions(
post: widget.post,
onLike: widget.onLike,
onComment: widget.onComment,
onShare: widget.onShare,
),
const SizedBox(height: DesignSystem.spacingMd),
// Statistiques
if (widget.post.likesCount > 0)
_PostStats(likesCount: widget.post.likesCount),
// Contenu du post
_PostContent(
post: widget.post,
showFullContent: _showFullContent,
onToggleFullContent: () {
setState(() {
_showFullContent = !_showFullContent;
});
},
),
// Lien vers les commentaires
if (widget.post.commentsCount > 0)
_CommentsLink(
commentsCount: widget.post.commentsCount,
onTap: widget.onComment,
),
],
),
),
],
),
);
}
}
/// Header du post avec avatar, nom, timestamp et menu.
class _PostHeader extends StatelessWidget {
const _PostHeader({
required this.post,
required this.showVerifiedBadge,
required this.showCategory,
required this.category,
required this.onEdit,
required this.onDelete,
});
final SocialPost post;
final bool showVerifiedBadge;
final bool showCategory;
final String? category;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
DesignSystem.spacingLg,
DesignSystem.spacingMd,
DesignSystem.spacingSm,
DesignSystem.spacingMd,
),
child: Row(
children: [
// Avatar
_UserAvatar(imageUrl: post.userProfileImageUrl),
const SizedBox(width: DesignSystem.spacingMd),
// Informations
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Nom
Flexible(
child: Text(
post.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Badge vérifié
if (showVerifiedBadge) ...[
const SizedBox(width: 4),
const VerifiedBadge(size: 14),
],
// Catégorie
if (showCategory && category != null) ...[
const SizedBox(width: 6),
CategoryBadge(category: category!),
],
],
),
// Timestamp
const SizedBox(height: 2),
_PostTimestamp(timestamp: post.timestamp),
],
),
),
// Menu
_PostMenu(
onEdit: onEdit,
onDelete: onDelete,
),
],
),
);
}
}
/// Avatar utilisateur réutilisable.
class _UserAvatar extends StatelessWidget {
const _UserAvatar({
required this.imageUrl,
this.size = 40,
});
final String imageUrl;
final double size;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final radius = size / 2;
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withOpacity(0.3),
theme.colorScheme.secondary.withOpacity(0.3),
],
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.1),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(2),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.scaffoldBackgroundColor,
),
padding: const EdgeInsets.all(1.5),
child: CircleAvatar(
radius: radius - 3.5,
backgroundColor: theme.colorScheme.surfaceVariant,
backgroundImage:
imageUrl.isNotEmpty ? NetworkImage(imageUrl) : null,
child: imageUrl.isEmpty
? Icon(
Icons.person_rounded,
size: radius - 2,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
),
);
}
}
/// Timestamp du post.
class _PostTimestamp extends StatelessWidget {
const _PostTimestamp({required this.timestamp});
final DateTime timestamp;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
_formatTimestamp(timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 12,
height: 1.2,
),
);
}
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inSeconds < 60) return 'À l\'instant';
if (difference.inMinutes < 60) return 'Il y a ${difference.inMinutes}min';
if (difference.inHours < 24) return 'Il y a ${difference.inHours}h';
if (difference.inDays < 7) return 'Il y a ${difference.inDays}j';
if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return 'Il y a ${weeks}sem';
}
return '${timestamp.day}/${timestamp.month}/${timestamp.year}';
}
}
/// Menu d'options du post.
class _PostMenu extends StatelessWidget {
const _PostMenu({
required this.onEdit,
required this.onDelete,
});
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_horiz_rounded, size: 20),
padding: EdgeInsets.zero,
iconSize: 20,
onSelected: (value) {
switch (value) {
case 'edit':
onEdit();
break;
case 'delete':
onDelete();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 12),
Text('Modifier', style: TextStyle(fontSize: 14)),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline, size: 18),
SizedBox(width: 12),
Text('Supprimer', style: TextStyle(fontSize: 14)),
],
),
),
],
);
}
}
/// Actions rapides du post (like, comment, share, bookmark).
class _PostActions extends StatefulWidget {
const _PostActions({
required this.post,
required this.onLike,
required this.onComment,
required this.onShare,
});
final SocialPost post;
final VoidCallback onLike;
final VoidCallback onComment;
final VoidCallback onShare;
@override
State<_PostActions> createState() => _PostActionsState();
}
class _PostActionsState extends State<_PostActions> {
bool _isBookmarked = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
// Like
SocialActionButton(
icon: widget.post.isLikedByCurrentUser
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
onTap: widget.onLike,
color: widget.post.isLikedByCurrentUser ? Colors.red : null,
tooltip: 'J\'aime',
),
const SizedBox(width: DesignSystem.spacingSm),
// Comment
SocialActionButton(
icon: Icons.chat_bubble_outline_rounded,
onTap: widget.onComment,
tooltip: 'Commenter',
),
const SizedBox(width: DesignSystem.spacingSm),
// Share
SocialActionButton(
icon: Icons.send_outlined,
onTap: widget.onShare,
tooltip: 'Partager',
),
const Spacer(),
// Bookmark
SocialActionButton(
icon: _isBookmarked
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
onTap: () {
setState(() {
_isBookmarked = !_isBookmarked;
});
},
color: _isBookmarked ? theme.colorScheme.primary : null,
tooltip: 'Enregistrer',
),
],
);
}
}
/// Statistiques du post (nombre de likes).
class _PostStats extends StatelessWidget {
const _PostStats({required this.likesCount});
final int likesCount;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: DesignSystem.spacingMd),
child: Text(
likesCount == 1 ? '1 j\'aime' : '$likesCount j\'aime',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: -0.1,
),
),
);
}
}
/// Contenu du post avec support des hashtags et mentions.
class _PostContent extends StatelessWidget {
const _PostContent({
required this.post,
required this.showFullContent,
required this.onToggleFullContent,
});
final SocialPost post;
final bool showFullContent;
final VoidCallback onToggleFullContent;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = 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: [
TextSpan(
text: '${post.authorFullName} ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
..._buildEnrichedContent(
shouldTruncate ? '${content.substring(0, 150)}...' : content,
theme,
),
],
),
),
if (content.length > 150)
GestureDetector(
onTap: onToggleFullContent,
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
showFullContent ? 'Voir moins' : 'Voir plus',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
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('#') || word.startsWith('@')) {
spans.add(
TextSpan(
text: word,
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
recognizer: TapGestureRecognizer()
..onTap = () {
debugPrint('Cliqué sur: $word');
},
),
);
} else {
spans.add(TextSpan(text: word));
}
if (i < words.length - 1) {
spans.add(const TextSpan(text: ' '));
}
}
return spans;
}
}
/// Lien vers les commentaires.
class _CommentsLink extends StatelessWidget {
const _CommentsLink({
required this.commentsCount,
required this.onTap,
});
final int commentsCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(top: DesignSystem.spacingMd),
child: Text(
commentsCount == 1
? 'Voir le commentaire'
: 'Voir les $commentsCount commentaires',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 13,
),
),
),
);
}
}

View File

@@ -0,0 +1,20 @@
/// Widgets réutilisables pour les posts sociaux.
///
/// Ce fichier exporte tous les widgets atomiques et composants
/// pour la création et l'affichage de posts sociaux.
// Widgets atomiques
export 'social_action_button.dart';
export 'social_badge.dart';
// Widgets de médias
export 'media_picker.dart';
export 'post_media_viewer.dart';
// Dialogues et composants complexes
export 'create_post_dialog.dart';
export 'edit_post_dialog.dart';
export 'social_card_refactored.dart';
// Lecteurs de médias
export 'fullscreen_video_player.dart';