Files
afterwork/lib/presentation/widgets/social/create_post_dialog.dart
dahoud 92612abbd7 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
2026-01-10 10:43:17 +00:00

437 lines
13 KiB
Dart

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 = '';
});
}
}
}
}