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,513 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import '../../../config/injection/injection.dart';
import '../../../core/constants/design_system.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/providers/presence_provider.dart';
import '../../../data/services/chat_websocket_service.dart';
import '../../../data/services/secure_storage.dart';
import '../../../domain/entities/chat_message.dart';
import '../../../domain/entities/conversation.dart';
import '../../state_management/chat_bloc.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/date_separator.dart';
import '../../widgets/message_bubble.dart';
import '../../widgets/shimmer_loading.dart';
import '../../widgets/typing_indicator_widget.dart';
/// Écran de chat pour une conversation individuelle.
class ChatScreen extends StatelessWidget {
const ChatScreen({
required this.conversation,
super.key,
});
final Conversation conversation;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => sl<ChatBloc>(),
child: _ChatScreenContent(conversation: conversation),
);
}
}
class _ChatScreenContent extends StatefulWidget {
const _ChatScreenContent({required this.conversation});
final Conversation conversation;
@override
State<_ChatScreenContent> createState() => _ChatScreenContentState();
}
class _ChatScreenContentState extends State<_ChatScreenContent> {
final SecureStorage _storage = SecureStorage();
late ChatWebSocketService _wsService;
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
String? _currentUserId;
bool _isTyping = false;
Timer? _typingTimer;
StreamSubscription<ChatMessage>? _messageSubscription;
StreamSubscription<TypingIndicator>? _typingSubscription;
StreamSubscription<DeliveryConfirmation>? _deliverySubscription;
@override
void initState() {
super.initState();
_initializeChat();
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_typingTimer?.cancel();
_messageSubscription?.cancel();
_typingSubscription?.cancel();
_deliverySubscription?.cancel();
_wsService.dispose();
super.dispose();
}
Future<void> _initializeChat() async {
_currentUserId = await _storage.getUserId();
if (_currentUserId == null) return;
// Créer une nouvelle instance du WebSocket service
_wsService = ChatWebSocketService(_currentUserId!);
// Se connecter au WebSocket
await _wsService.connect();
// S'abonner aux nouveaux messages
_messageSubscription = _wsService.messageStream.listen((message) {
if (message.conversationId == widget.conversation.id) {
// Ajouter le message au Bloc
if (mounted) {
context.read<ChatBloc>().add(AddMessageToConversation(message));
}
_scrollToBottom();
// Marquer comme lu si ce n'est pas notre message
if (message.senderId != _currentUserId) {
_markAsRead(message.id);
}
}
});
// S'abonner aux indicateurs de frappe
_typingSubscription = _wsService.typingStream.listen((indicator) {
if (indicator.conversationId == widget.conversation.id &&
indicator.userId != _currentUserId) {
if (mounted) {
setState(() {
_isTyping = indicator.isTyping;
});
}
}
});
// S'abonner aux confirmations de délivrance
_deliverySubscription = _wsService.deliveryStream.listen((confirmation) {
AppLogger.d('Delivery confirmation reçue: ${confirmation.messageId}', tag: 'ChatScreen');
if (mounted) {
AppLogger.d('Envoi MarkMessageAsDelivered au ChatBloc', tag: 'ChatScreen');
context.read<ChatBloc>().add(MarkMessageAsDelivered(confirmation.messageId));
}
});
// Charger les messages via le Bloc
if (mounted) {
context.read<ChatBloc>().add(LoadMessages(conversationId: widget.conversation.id));
}
}
void _sendMessage() {
final content = _messageController.text.trim();
if (content.isEmpty || _currentUserId == null) return;
_messageController.clear();
// Arrêter l'indicateur de frappe
_wsService.sendTypingIndicator(widget.conversation.id, false);
// Envoyer via le Bloc
context.read<ChatBloc>().add(SendMessage(
senderId: _currentUserId!,
recipientId: widget.conversation.participantId,
content: content,
));
_scrollToBottom();
}
void _markAsRead(String messageId) {
context.read<ChatBloc>().add(MarkMessageAsRead(messageId));
_wsService.sendReadReceipt(messageId);
}
void _onTextChanged(String text) {
// Envoyer l'indicateur de frappe après 500ms d'inactivité
_typingTimer?.cancel();
if (text.isNotEmpty) {
_wsService.sendTypingIndicator(widget.conversation.id, true);
_typingTimer = Timer(const Duration(milliseconds: 1500), () {
_wsService.sendTypingIndicator(widget.conversation.id, false);
});
} else {
_wsService.sendTypingIndicator(widget.conversation.id, false);
}
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
}
/// Vérifie si deux dates sont le même jour.
bool _isSameDay(DateTime date1, DateTime date2) {
return date1.year == date2.year &&
date1.month == date2.month &&
date1.day == date2.day;
}
/// Calcule le nombre total d'items (messages + séparateurs).
int _getTotalItemCount(List<ChatMessage> messages) {
if (messages.isEmpty) return 0;
int separatorCount = 0;
DateTime? lastDate;
for (var message in messages) {
final messageDate = DateTime(
message.timestamp.year,
message.timestamp.month,
message.timestamp.day,
);
if (lastDate == null || !_isSameDay(lastDate, messageDate)) {
separatorCount++;
lastDate = messageDate;
}
}
return messages.length + separatorCount;
}
/// Détermine si l'index donné correspond à un séparateur de date.
bool _isDateSeparatorAtIndex(int displayIndex, List<ChatMessage> messages) {
if (messages.isEmpty) return false;
int itemsProcessed = 0;
DateTime? lastDate;
for (int i = 0; i < messages.length; i++) {
final messageDate = DateTime(
messages[i].timestamp.year,
messages[i].timestamp.month,
messages[i].timestamp.day,
);
if (lastDate == null || !_isSameDay(lastDate, messageDate)) {
if (itemsProcessed == displayIndex) {
return true; // C'est un séparateur
}
itemsProcessed++;
lastDate = messageDate;
}
if (itemsProcessed == displayIndex) {
return false; // C'est un message
}
itemsProcessed++;
}
return false;
}
/// Obtient l'index réel du message en tenant compte des séparateurs.
int _getMessageIndex(int displayIndex, List<ChatMessage> messages) {
if (messages.isEmpty) return 0;
int itemsProcessed = 0;
int messageIndex = 0;
DateTime? lastDate;
for (int i = 0; i < messages.length; i++) {
final messageDate = DateTime(
messages[i].timestamp.year,
messages[i].timestamp.month,
messages[i].timestamp.day,
);
if (lastDate == null || !_isSameDay(lastDate, messageDate)) {
if (itemsProcessed == displayIndex) {
return i; // On retourne l'index du premier message de ce groupe
}
itemsProcessed++;
lastDate = messageDate;
}
if (itemsProcessed == displayIndex) {
return i;
}
itemsProcessed++;
}
return messageIndex;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Hero(
tag: 'chat_avatar_${widget.conversation.participantId}',
child: CircleAvatar(
radius: 18,
backgroundImage: widget.conversation.participantProfileImageUrl != null
? NetworkImage(widget.conversation.participantProfileImageUrl!)
: null,
child: widget.conversation.participantProfileImageUrl == null
? Text(
widget.conversation.participantFirstName[0].toUpperCase(),
style: const TextStyle(fontSize: 16),
)
: null,
),
),
const SizedBox(width: DesignSystem.spacingSm),
Expanded(
child: Consumer<PresenceProvider>(
builder: (context, presenceProvider, child) {
final isOnline = presenceProvider.isUserOnline(widget.conversation.participantId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.conversation.participantFullName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (_isTyping)
Text(
'En train d\'écrire...',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontStyle: FontStyle.italic,
),
)
else
Text(
isOnline ? 'En ligne' : 'Hors ligne',
style: theme.textTheme.bodySmall?.copyWith(
color: isOnline ? Colors.green : Colors.grey,
),
),
],
);
},
),
),
],
),
),
body: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) {
if (state.messagesStatus == MessagesStatus.error) {
context.showError(state.errorMessage ?? 'Erreur de chargement');
}
if (state.sendMessageStatus == SendMessageStatus.error) {
context.showError(state.errorMessage ?? 'Erreur d\'envoi');
}
if (state.sendMessageStatus == SendMessageStatus.success) {
_scrollToBottom();
}
},
builder: (context, state) {
return Column(
children: [
// Liste des messages
Expanded(
child: state.messagesStatus == MessagesStatus.loading
? const SkeletonList(
itemCount: 10,
skeletonWidget: ListItemSkeleton(),
)
: state.messagesStatus == MessagesStatus.error
? _buildErrorState(theme, state.errorMessage)
: state.messages.isEmpty
? _buildEmptyState(theme)
: ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.all(DesignSystem.spacingLg),
itemCount: _getTotalItemCount(state.messages) + (_isTyping ? 1 : 0),
itemBuilder: (context, index) {
// Afficher l'indicateur de frappe en premier (index 0)
if (_isTyping && index == 0) {
return const Padding(
padding: EdgeInsets.only(bottom: DesignSystem.spacingSm),
child: TypingIndicatorWidget(),
);
}
final actualIndex = _isTyping ? index - 1 : index;
// Vérifier si c'est un séparateur de date
if (_isDateSeparatorAtIndex(actualIndex, state.messages)) {
final messageIndex = _getMessageIndex(actualIndex, state.messages);
final message = state.messages[messageIndex];
return DateSeparator(date: message.timestamp);
}
// C'est un message normal
final messageIndex = _getMessageIndex(actualIndex, state.messages);
final message = state.messages[messageIndex];
final isCurrentUser = message.senderId == _currentUserId;
return MessageBubble(
message: message,
isCurrentUser: isCurrentUser,
);
},
),
),
// Barre de saisie
Container(
padding: EdgeInsets.only(
left: DesignSystem.spacingLg,
right: DesignSystem.spacingLg,
top: DesignSystem.spacingSm,
bottom: MediaQuery.of(context).viewInsets.bottom + DesignSystem.spacingSm,
),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
border: Border(
top: BorderSide(color: theme.dividerColor),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
onChanged: _onTextChanged,
decoration: InputDecoration(
hintText: 'Écrivez un message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: DesignSystem.spacingSm),
state.sendMessageStatus == SendMessageStatus.sending
? const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
onPressed: _sendMessage,
icon: const Icon(Icons.send),
color: theme.colorScheme.primary,
iconSize: 28,
),
],
),
),
],
);
},
),
);
}
Widget _buildErrorState(ThemeData theme, String? errorMessage) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: DesignSystem.spacingLg),
Text('Erreur de chargement', style: theme.textTheme.titleLarge),
const SizedBox(height: DesignSystem.spacingSm),
Text(
errorMessage ?? 'Une erreur est survenue',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: DesignSystem.spacingXl),
ElevatedButton.icon(
onPressed: () {
context.read<ChatBloc>().add(LoadMessages(conversationId: widget.conversation.id));
},
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
);
}
Widget _buildEmptyState(ThemeData theme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: DesignSystem.spacingLg),
Text(
'Aucun message',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: DesignSystem.spacingSm),
Text(
'Envoyez le premier message !',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,464 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../config/injection/injection.dart';
import '../../../core/constants/design_system.dart';
import '../../../core/utils/page_transitions.dart';
import '../../../data/services/secure_storage.dart';
import '../../../domain/entities/conversation.dart';
import '../../state_management/chat_bloc.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/modern_empty_state.dart';
import '../../widgets/shimmer_loading.dart';
import 'chat_screen.dart';
/// Écran de la liste des conversations.
class ConversationsScreen extends StatelessWidget {
const ConversationsScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => sl<ChatBloc>(),
child: const _ConversationsScreenContent(),
);
}
}
class _ConversationsScreenContent extends StatefulWidget {
const _ConversationsScreenContent();
@override
State<_ConversationsScreenContent> createState() => _ConversationsScreenContentState();
}
class _ConversationsScreenContentState extends State<_ConversationsScreenContent> {
final SecureStorage _storage = SecureStorage();
String? _currentUserId;
@override
void initState() {
super.initState();
_loadConversations();
}
Future<void> _loadConversations() async {
final userId = await _storage.getUserId();
if (userId == null) return;
setState(() {
_currentUserId = userId;
});
if (mounted) {
context.read<ChatBloc>().add(LoadConversations(userId));
context.read<ChatBloc>().add(LoadUnreadCount(userId));
}
}
void _openConversation(Conversation conversation) {
context.pushSlideUp(ChatScreen(conversation: conversation));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Messages'),
actions: [
BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
if (state.unreadCount > 0) {
return Stack(
children: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {
Navigator.of(context).pushNamed('/notifications');
},
),
Positioned(
right: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.colorScheme.error,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(
minWidth: 16,
minHeight: 16,
),
child: Text(
state.unreadCount > 9 ? '9+' : state.unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
return const SizedBox.shrink();
},
),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
showSearch(
context: context,
delegate: ConversationSearchDelegate(
conversations: context.read<ChatBloc>().state.conversations,
currentUserId: _currentUserId,
),
);
},
),
],
),
body: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) {
if (state.conversationsStatus == ConversationsStatus.error) {
context.showError(state.errorMessage ?? 'Erreur de chargement');
}
},
builder: (context, state) {
if (state.conversationsStatus == ConversationsStatus.loading) {
return const SkeletonList(
itemCount: 8,
skeletonWidget: ListItemSkeleton(),
);
}
if (state.conversationsStatus == ConversationsStatus.error) {
return _buildErrorState(theme, state.errorMessage);
}
if (state.conversations.isEmpty) {
return _buildEmptyState();
}
// Trier les conversations par dernière activité
final sortedConversations = List<Conversation>.from(state.conversations);
sortedConversations.sort((a, b) {
if (a.lastMessageTimestamp == null && b.lastMessageTimestamp == null) return 0;
if (a.lastMessageTimestamp == null) return 1;
if (b.lastMessageTimestamp == null) return -1;
return b.lastMessageTimestamp!.compareTo(a.lastMessageTimestamp!);
});
return RefreshIndicator(
onRefresh: _loadConversations,
child: ListView.separated(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
itemCount: sortedConversations.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor,
),
itemBuilder: (context, index) {
return _buildConversationCard(sortedConversations[index]);
},
),
);
},
),
);
}
Widget _buildConversationCard(Conversation conversation) {
final theme = Theme.of(context);
final timeFormat = conversation.lastMessageTimestamp != null
? _getTimeFormat(conversation.lastMessageTimestamp!)
: '';
return FadeInWidget(
child: AnimatedCard(
margin: const EdgeInsets.only(bottom: DesignSystem.spacingSm),
padding: const EdgeInsets.all(DesignSystem.spacingLg),
onTap: () => _openConversation(conversation),
child: Row(
children: [
// Avatar avec badge pour les non lus
Stack(
children: [
Hero(
tag: 'conversation_avatar_${conversation.participantId}',
child: CircleAvatar(
radius: 28,
backgroundImage: conversation.participantProfileImageUrl != null
? NetworkImage(conversation.participantProfileImageUrl!)
: null,
child: conversation.participantProfileImageUrl == null
? Text(
conversation.participantFirstName[0].toUpperCase(),
style: const TextStyle(fontSize: 20),
)
: null,
),
),
if (conversation.hasUnreadMessages)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.colorScheme.error,
shape: BoxShape.circle,
border: Border.all(
color: theme.scaffoldBackgroundColor,
width: 2,
),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: Text(
conversation.unreadCount > 9 ? '9+' : conversation.unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
),
const SizedBox(width: DesignSystem.spacingLg),
// Informations de la conversation
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
conversation.participantFullName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: conversation.hasUnreadMessages
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (conversation.lastMessageTimestamp != null) ...[
const SizedBox(width: DesignSystem.spacingSm),
Text(
timeFormat,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontWeight: conversation.hasUnreadMessages
? FontWeight.bold
: FontWeight.normal,
),
),
],
],
),
const SizedBox(height: 4),
Row(
children: [
if (conversation.isTyping) ...[
Text(
'En train d\'écrire...',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
] else if (conversation.hasLastMessage) ...[
Expanded(
child: Text(
conversation.lastMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: conversation.hasUnreadMessages
? theme.textTheme.bodyMedium?.color
: Colors.grey[600],
fontWeight: conversation.hasUnreadMessages
? FontWeight.w500
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
],
),
),
],
),
),
);
}
String _getTimeFormat(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inDays == 0) {
// Aujourd'hui - afficher l'heure
return DateFormat('HH:mm').format(timestamp);
} else if (difference.inDays == 1) {
// Hier
return 'Hier';
} else if (difference.inDays < 7) {
// Cette semaine - afficher le jour
return DateFormat('EEEE', 'fr_FR').format(timestamp);
} else {
// Plus ancien - afficher la date
return DateFormat('dd/MM/yyyy').format(timestamp);
}
}
Widget _buildErrorState(ThemeData theme, String? errorMessage) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: DesignSystem.spacingLg),
Text('Erreur de chargement', style: theme.textTheme.titleLarge),
const SizedBox(height: DesignSystem.spacingSm),
Text(
errorMessage ?? 'Une erreur est survenue',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: DesignSystem.spacingXl),
ElevatedButton.icon(
onPressed: _loadConversations,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
);
}
Widget _buildEmptyState() {
return ModernEmptyState(
illustration: EmptyStateIllustration.social,
title: 'Aucune conversation',
description: 'Commencez à discuter avec vos amis !',
actionLabel: 'Voir mes amis',
onAction: () {
Navigator.pop(context);
},
);
}
}
/// Delegate de recherche pour les conversations
class ConversationSearchDelegate extends SearchDelegate<Conversation?> {
ConversationSearchDelegate({
required this.conversations,
required this.currentUserId,
});
final List<Conversation> conversations;
final String? currentUserId;
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
},
),
];
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
close(context, null);
},
);
}
@override
Widget buildResults(BuildContext context) {
return _buildSearchResults(context);
}
@override
Widget buildSuggestions(BuildContext context) {
return _buildSearchResults(context);
}
Widget _buildSearchResults(BuildContext context) {
if (query.isEmpty) {
return const Center(
child: Text('Rechercher une conversation...'),
);
}
final results = conversations.where((conv) {
final fullName = '${conv.participantFirstName} ${conv.participantLastName}'.toLowerCase();
final lastMessage = conv.lastMessage.toLowerCase();
final searchQuery = query.toLowerCase();
return fullName.contains(searchQuery) || lastMessage.contains(searchQuery);
}).toList();
if (results.isEmpty) {
return const Center(
child: Text('Aucun résultat trouvé'),
);
}
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final conversation = results[index];
return ListTile(
leading: CircleAvatar(
backgroundImage: conversation.participantProfileImageUrl != null
? NetworkImage(conversation.participantProfileImageUrl!)
: null,
child: conversation.participantProfileImageUrl == null
? Text(conversation.participantFirstName[0].toUpperCase())
: null,
),
title: Text(conversation.participantFullName),
subtitle: Text(
conversation.lastMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () {
close(context, conversation);
Navigator.of(context).pushNamed(
'/chat',
arguments: conversation,
);
},
);
},
);
}
}

View File

@@ -1,39 +1,39 @@
import 'dart:io'; // Pour l'usage des fichiers (image)
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'dart:io'; // Pour l'usage des fichiers (image)
import '../../widgets/fields/category_field.dart'; // Importation des widgets personnalisés
import '../../../core/utils/app_logger.dart';
import '../../widgets/date_picker.dart';
import '../../widgets/fields/accessibility_field.dart';
import '../../widgets/fields/accommodation_info_field.dart';
import '../../widgets/fields/attendees_field.dart';
import '../../widgets/fields/category_field.dart'; // Importation des widgets personnalisés
import '../../widgets/fields/description_field.dart';
import '../../widgets/fields/link_field.dart';
import '../../widgets/fields/location_field.dart';
import '../../widgets/submit_button.dart';
import '../../widgets/fields/title_field.dart';
import '../../widgets/image_preview_picker.dart';
import '../../widgets/fields/tags_field.dart';
import '../../widgets/fields/attendees_field.dart';
import '../../widgets/fields/organizer_field.dart';
import '../../widgets/fields/transport_info_field.dart';
import '../../widgets/fields/accommodation_info_field.dart';
import '../../widgets/fields/parking_field.dart';
import '../../widgets/fields/participation_fee_field.dart';
import '../../widgets/fields/privacy_rules_field.dart';
import '../../widgets/fields/security_protocol_field.dart';
import '../../widgets/fields/parking_field.dart';
import '../../widgets/fields/accessibility_field.dart';
import '../../widgets/fields/participation_fee_field.dart';
import '../../widgets/fields/tags_field.dart';
import '../../widgets/fields/title_field.dart';
import '../../widgets/fields/transport_info_field.dart';
import '../../widgets/image_preview_picker.dart';
import '../../widgets/submit_button.dart';
/// Page pour ajouter un événement
/// Permet à l'utilisateur de remplir un formulaire avec des détails sur l'événement
class AddEventPage extends StatefulWidget {
const AddEventPage({
required this.userId, required this.userFirstName, required this.userLastName, super.key,
});
final String userId;
final String userFirstName;
final String userLastName;
const AddEventPage({
super.key,
required this.userId,
required this.userFirstName,
required this.userLastName,
});
@override
_AddEventPageState createState() => _AddEventPageState();
}
@@ -52,16 +52,16 @@ class _AddEventPageState extends State<AddEventPage> {
String _organizer = '';
List<String> _tags = [];
int _maxParticipants = 0;
LatLng? _selectedLatLng = const LatLng(5.348722, -3.985038); // Coordonnées par défaut
final LatLng _selectedLatLng = const LatLng(5.348722, -3.985038); // Coordonnées par défaut
File? _selectedImageFile; // Image sélectionnée
String _status = 'Actif';
String _organizerEmail = '';
String _organizerPhone = '';
final String _status = 'Actif';
final String _organizerEmail = '';
final String _organizerPhone = '';
int _participationFee = 0;
String _privacyRules = '';
String _transportInfo = '';
String _accommodationInfo = '';
bool _isAccessible = false;
final bool _isAccessible = false;
bool _hasParking = false;
String _securityProtocol = '';
@@ -83,7 +83,7 @@ class _AddEventPageState extends State<AddEventPage> {
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
@@ -173,27 +173,18 @@ class _AddEventPageState extends State<AddEventPage> {
),
const SizedBox(height: 12),
SubmitButton(
text: 'Créer l\'événement',
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
// Log des données de l'événement avant l'envoi
print('Titre de l\'événement : $_title');
print('Description de l\'événement : $_description');
print('Date de début : $_selectedDate');
print('Date de fin : $_endDate');
print('Lieu : $_location');
print('Catégorie : $_category');
print('Lien de l\'événement : $_link');
print('Organisateur : $_organizer');
print('Tags : $_tags');
print('Maximum de participants : $_maxParticipants');
print('Image sélectionnée : $_selectedImageFile');
print('Transport : $_transportInfo');
print('Hébergement : $_accommodationInfo');
print('Règles de confidentialité : $_privacyRules');
print('Protocole de sécurité : $_securityProtocol');
print('Parking disponible : $_hasParking');
print('Accessibilité : $_isAccessible');
print('Frais de participation : $_participationFee');
AppLogger.i(
'Création d\'événement: titre=$_title, lieu=$_location, catégorie=$_category, participants=$_maxParticipants',
tag: 'AddEventDialog',
);
AppLogger.d(
'Détails: description=${_description.length} chars, dates=$_selectedDate -> $_endDate, tags=${_tags.length}, image=${_selectedImageFile != null}',
tag: 'AddEventDialog',
);
// Logique d'envoi des données vers le backend...
ScaffoldMessenger.of(context).showSnackBar(
@@ -219,7 +210,7 @@ class _AddEventPageState extends State<AddEventPage> {
// En-tête de section pour mieux organiser les champs
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
title,
style: const TextStyle(

View File

@@ -1,36 +1,564 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// Écran des établissements.
/// Cet écran affiche une liste des établissements disponibles.
/// Les logs permettent de tracer les actions de navigation et d'affichage dans cet écran.
class EstablishmentsScreen extends StatelessWidget {
const EstablishmentsScreen({Key? key}) : super(key: key);
import '../../../core/constants/design_system.dart';
import '../../../data/datasources/establishment_remote_data_source.dart';
import '../../../domain/entities/establishment.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/modern_empty_state.dart';
import '../../widgets/shimmer_loading.dart';
/// Écran des établissements avec recherche et filtres.
class EstablishmentsScreen extends StatefulWidget {
const EstablishmentsScreen({super.key});
@override
Widget build(BuildContext context) {
print("Affichage de l'écran des établissements.");
State<EstablishmentsScreen> createState() => _EstablishmentsScreenState();
}
return Scaffold(
appBar: AppBar(
title: const Text('Établissements'),
backgroundColor: Colors.blueAccent,
),
body: ListView.builder(
itemCount: 10, // Exemple : 10 établissements fictifs pour l'affichage
itemBuilder: (context, index) {
print("Affichage de l'établissement numéro $index.");
class _EstablishmentsScreenState extends State<EstablishmentsScreen> {
final EstablishmentRemoteDataSource _dataSource = EstablishmentRemoteDataSource(http.Client());
final TextEditingController _searchController = TextEditingController();
return ListTile(
leading: const Icon(Icons.location_city),
title: Text('Établissement $index'),
subtitle: const Text('Description de l\'établissement'),
onTap: () {
print("L'utilisateur a sélectionné l'établissement numéro $index.");
// Logique pour ouvrir les détails de l'établissement
},
);
List<Establishment> _establishments = [];
List<Establishment> _filteredEstablishments = [];
bool _isLoading = false;
String? _errorMessage;
// Filtres
EstablishmentType? _selectedType;
PriceRange? _selectedPriceRange;
String? _selectedCity;
final List<String> _availableCities = [];
@override
void initState() {
super.initState();
_loadEstablishments();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadEstablishments() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final models = await _dataSource.getAllEstablishments();
if (mounted) {
setState(() {
_establishments = models.map((m) => m.toEntity()).toList();
_filteredEstablishments = _establishments;
// Extraire les villes uniques
final cities = _establishments.map((e) => e.city).toSet().toList();
_availableCities.clear();
_availableCities.addAll(cities);
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
void _onSearchChanged() {
_applyFilters();
}
void _applyFilters() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredEstablishments = _establishments.where((establishment) {
// Filtre par recherche (nom ou ville)
final matchesSearch = query.isEmpty ||
establishment.name.toLowerCase().contains(query) ||
establishment.city.toLowerCase().contains(query);
// Filtre par type
final matchesType = _selectedType == null || establishment.type == _selectedType;
// Filtre par prix
final matchesPrice = _selectedPriceRange == null || establishment.priceRange == _selectedPriceRange;
// Filtre par ville
final matchesCity = _selectedCity == null || establishment.city == _selectedCity;
return matchesSearch && matchesType && matchesPrice && matchesCity;
}).toList();
});
}
void _clearFilters() {
setState(() {
_selectedType = null;
_selectedPriceRange = null;
_selectedCity = null;
_searchController.clear();
_applyFilters();
});
}
Future<void> _showFilters() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _FiltersBottomSheet(
selectedType: _selectedType,
selectedPriceRange: _selectedPriceRange,
selectedCity: _selectedCity,
availableCities: _availableCities,
onApply: (type, priceRange, city) {
setState(() {
_selectedType = type;
_selectedPriceRange = priceRange;
_selectedCity = city;
});
_applyFilters();
},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasActiveFilters = _selectedType != null || _selectedPriceRange != null || _selectedCity != null;
return Scaffold(
body: Column(
children: [
// Barre de recherche et filtres
Container(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
children: [
// Champ de recherche
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher un établissement...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
filled: true,
),
),
const SizedBox(height: DesignSystem.spacingSm),
// Boutons de filtres
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _showFilters,
icon: Icon(
Icons.filter_list,
color: hasActiveFilters ? theme.colorScheme.primary : null,
),
label: Text(
hasActiveFilters ? 'Filtres actifs' : 'Filtres',
style: TextStyle(
color: hasActiveFilters ? theme.colorScheme.primary : null,
fontWeight: hasActiveFilters ? FontWeight.bold : null,
),
),
style: OutlinedButton.styleFrom(
side: BorderSide(
color: hasActiveFilters
? theme.colorScheme.primary
: theme.dividerColor,
width: hasActiveFilters ? 2 : 1,
),
),
),
),
if (hasActiveFilters) ...[
const SizedBox(width: DesignSystem.spacingSm),
IconButton(
onPressed: _clearFilters,
icon: const Icon(Icons.clear_all),
tooltip: 'Effacer les filtres',
),
],
],
),
],
),
),
// Liste des établissements
Expanded(
child: _isLoading
? const SkeletonList(
itemCount: 6,
skeletonWidget: ListItemSkeleton(),
)
: _errorMessage != null
? _buildErrorState(theme)
: _filteredEstablishments.isEmpty
? _buildEmptyState()
: RefreshIndicator(
onRefresh: _loadEstablishments,
child: ListView.builder(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
itemCount: _filteredEstablishments.length,
itemBuilder: (context, index) {
return _buildEstablishmentCard(
_filteredEstablishments[index],
);
},
),
),
),
],
),
);
}
Widget _buildErrorState(ThemeData theme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: DesignSystem.spacingLg),
Text('Erreur de chargement', style: theme.textTheme.titleLarge),
const SizedBox(height: DesignSystem.spacingSm),
Text(_errorMessage!, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
const SizedBox(height: DesignSystem.spacingXl),
ElevatedButton.icon(
onPressed: _loadEstablishments,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
);
}
Widget _buildEmptyState() {
return ModernEmptyState(
illustration: EmptyStateIllustration.search,
title: 'Aucun établissement trouvé',
description: _searchController.text.isNotEmpty || (_selectedType != null || _selectedPriceRange != null || _selectedCity != null)
? 'Essayez de modifier vos critères de recherche'
: 'Aucun établissement disponible pour le moment',
);
}
Widget _buildEstablishmentCard(Establishment establishment) {
final theme = Theme.of(context);
return FadeInWidget(
child: AnimatedCard(
margin: const EdgeInsets.only(bottom: DesignSystem.spacingLg),
onTap: () {
// TODO: Naviguer vers les détails
context.showInfo('Détails de ${establishment.name}');
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
if (establishment.imageUrl != null)
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusMd),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
establishment.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.location_city, size: 48),
);
},
),
),
),
Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom et type
Row(
children: [
Text(
establishment.type.icon,
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: DesignSystem.spacingSm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
establishment.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
establishment.type.displayName,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
if (establishment.priceRange != null)
Text(
establishment.priceRange!.symbol,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: DesignSystem.spacingSm),
// Adresse
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
establishment.fullAddress,
style: theme.textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
// Note
if (establishment.rating != null) ...[
const SizedBox(height: DesignSystem.spacingSm),
Row(
children: [
const Icon(Icons.star, size: 16, color: Colors.amber),
const SizedBox(width: 4),
Text(
establishment.rating!.toStringAsFixed(1),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
' / 5.0',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
],
],
),
),
],
),
),
);
}
}
/// Bottom sheet pour les filtres
class _FiltersBottomSheet extends StatefulWidget {
const _FiltersBottomSheet({
required this.selectedType,
required this.selectedPriceRange,
required this.selectedCity,
required this.availableCities,
required this.onApply,
});
final EstablishmentType? selectedType;
final PriceRange? selectedPriceRange;
final String? selectedCity;
final List<String> availableCities;
final void Function(EstablishmentType?, PriceRange?, String?) onApply;
@override
State<_FiltersBottomSheet> createState() => _FiltersBottomSheetState();
}
class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
late EstablishmentType? _type;
late PriceRange? _priceRange;
late String? _city;
@override
void initState() {
super.initState();
_type = widget.selectedType;
_priceRange = widget.selectedPriceRange;
_city = widget.selectedCity;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(DesignSystem.radiusLg)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + DesignSystem.spacingLg,
top: DesignSystem.spacingLg,
left: DesignSystem.spacingLg,
right: DesignSystem.spacingLg,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Handle bar
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: DesignSystem.spacingLg),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
),
Text('Filtres', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: DesignSystem.spacingXl),
// Type
Text('Type d\'établissement', style: theme.textTheme.titleSmall),
const SizedBox(height: DesignSystem.spacingSm),
Wrap(
spacing: DesignSystem.spacingSm,
runSpacing: DesignSystem.spacingSm,
children: EstablishmentType.values.map((type) {
final isSelected = _type == type;
return FilterChip(
label: Text(type.displayName),
selected: isSelected,
onSelected: (selected) {
setState(() {
_type = selected ? type : null;
});
},
);
}).toList(),
),
const SizedBox(height: DesignSystem.spacingXl),
// Prix
Text('Fourchette de prix', style: theme.textTheme.titleSmall),
const SizedBox(height: DesignSystem.spacingSm),
Wrap(
spacing: DesignSystem.spacingSm,
children: PriceRange.values.map((price) {
final isSelected = _priceRange == price;
return FilterChip(
label: Text(price.symbol),
selected: isSelected,
onSelected: (selected) {
setState(() {
_priceRange = selected ? price : null;
});
},
);
}).toList(),
),
const SizedBox(height: DesignSystem.spacingXl),
// Ville
if (widget.availableCities.isNotEmpty) ...[
Text('Ville', style: theme.textTheme.titleSmall),
const SizedBox(height: DesignSystem.spacingSm),
DropdownButtonFormField<String>(
value: _city,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
contentPadding: const EdgeInsets.symmetric(horizontal: DesignSystem.spacingLg, vertical: DesignSystem.spacingSm),
),
hint: const Text('Toutes les villes'),
items: widget.availableCities.map((city) {
return DropdownMenuItem(value: city, child: Text(city));
}).toList(),
onChanged: (value) {
setState(() {
_city = value;
});
},
),
const SizedBox(height: DesignSystem.spacingXl),
],
// Boutons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() {
_type = null;
_priceRange = null;
_city = null;
});
},
child: const Text('Réinitialiser'),
),
),
const SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: ElevatedButton(
onPressed: () {
widget.onApply(_type, _priceRange, _city);
Navigator.pop(context);
},
child: const Text('Appliquer'),
),
),
],
),
],
),
);
}
}

View File

@@ -1,32 +1,29 @@
import 'package:flutter/material.dart';
import 'package:logger/logger.dart'; // Pour la gestion des logs.
import '../../../core/constants/design_system.dart';
import '../../../core/constants/env_config.dart';
import '../../../core/utils/date_formatter.dart';
import '../../../data/models/event_model.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/event_header.dart';
import '../../widgets/event_image.dart';
import '../../widgets/event_interaction_row.dart';
import '../../widgets/event_status_badge.dart';
import '../../widgets/swipe_background.dart';
/// Widget représentant une carte d'événement affichant les informations
/// principales de l'événement avec diverses options d'interaction.
/// Widget représentant une carte d'événement avec design moderne et compact.
///
/// Cette carte affiche les informations principales de l'événement avec
/// diverses options d'interaction et un design optimisé.
///
/// **Fonctionnalités:**
/// - Affichage des informations de l'événement
/// - Interactions (réagir, commenter, partager, participer)
/// - Actions de fermeture/réouverture
/// - Swipe pour actions rapides
/// - Description expandable
class EventCard extends StatefulWidget {
final EventModel event; // Modèle de données pour l'événement.
final String userId; // ID de l'utilisateur affichant l'événement.
final String userFirstName; // Prénom de l'utilisateur.
final String userLastName; // Nom de l'utilisateur.
final String profileImageUrl; // Image de profile
final String status; // Statut de l'événement (ouvert ou fermé).
final VoidCallback onReact; // Callback pour réagir à l'événement.
final VoidCallback onComment; // Callback pour commenter l'événement.
final VoidCallback onShare; // Callback pour partager l'événement.
final VoidCallback onParticipate; // Callback pour participer à l'événement.
final VoidCallback onCloseEvent; // Callback pour fermer l'événement.
final VoidCallback onReopenEvent; // Callback pour rouvrir l'événement.
final Function onRemoveEvent; // Fonction pour supprimer l'événement.
const EventCard({
Key? key,
required this.event,
required this.userId,
required this.userFirstName,
@@ -40,150 +37,222 @@ class EventCard extends StatefulWidget {
required this.onCloseEvent,
required this.onReopenEvent,
required this.onRemoveEvent,
}) : super(key: key);
super.key,
});
final EventModel event;
final String userId;
final String userFirstName;
final String userLastName;
final String profileImageUrl;
final String status;
final VoidCallback onReact;
final VoidCallback onComment;
final VoidCallback onShare;
final VoidCallback onParticipate;
final VoidCallback onCloseEvent;
final VoidCallback onReopenEvent;
final Function(String) onRemoveEvent;
@override
_EventCardState createState() => _EventCardState();
State<EventCard> createState() => _EventCardState();
}
class _EventCardState extends State<EventCard> {
bool _isExpanded = false; // Contrôle si la description est développée.
static const int _descriptionThreshold = 100; // Limite de caractères.
bool _isClosed = false; // Ajout d'une variable pour suivre l'état de l'événement.
final Logger _logger = Logger();
// ============================================================================
// ÉTATS
// ============================================================================
@override
void initState() {
super.initState();
_isClosed = widget.event.status == 'fermé'; // Initialiser l'état selon le statut de l'événement.
}
bool _isDescriptionExpanded = false;
static const int _descriptionThreshold = 100;
bool get _isClosed => widget.event.status.toLowerCase() == 'fermé';
bool get _shouldTruncateDescription =>
widget.event.description.length > _descriptionThreshold;
// ============================================================================
// BUILD
// ============================================================================
@override
Widget build(BuildContext context) {
_logger.i("Construction de la carte d'événement"); // Log pour la construction du widget.
final GlobalKey menuKey = GlobalKey(); // Clé pour le menu contextuel.
final String descriptionText = widget.event.description; // Description de l'événement.
final bool shouldTruncate = descriptionText.length > _descriptionThreshold; // Détermine si le texte doit être tronqué.
final theme = Theme.of(context);
final menuKey = GlobalKey();
return Dismissible(
key: ValueKey(widget.event.id), // Clé unique pour chaque carte d'événement.
direction: widget.event.status == 'fermé' // Direction du glissement basée sur le statut.
key: ValueKey(widget.event.id),
direction: _isClosed
? DismissDirection.startToEnd
: DismissDirection.endToStart,
onDismissed: (direction) { // Action déclenchée lors d'un glissement.
if (_isClosed) {
_logger.i("Rouverte de l'événement ${widget.event.id}");
widget.onReopenEvent();
setState(() {
_isClosed = false; // Mise à jour de l'état local.
});
} else {
_logger.i("Fermeture de l'événement ${widget.event.id}");
widget.onCloseEvent();
widget.onRemoveEvent(widget.event.id); // Suppression de l'événement.
setState(() {
_isClosed = true; // Mise à jour de l'état local.
});
}
},
background: SwipeBackground( // Arrière-plan pour les actions de glissement.
color: _isClosed ? Colors.green : Colors.red,
icon: _isClosed ? Icons.lock_open : Icons.lock,
label: _isClosed ? 'Rouvrir' : 'Fermer',
),
child: Card(
color: const Color(0xFF2C2C3E), // Couleur de fond de la carte.
margin: const EdgeInsets.symmetric(vertical: 10.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), // Bordure arrondie.
onDismissed: _handleDismiss,
background: _buildSwipeBackground(),
child: AnimatedCard(
margin: const EdgeInsets.only(bottom: DesignSystem.spacingMd),
borderRadius: DesignSystem.borderRadiusMd,
elevation: 1,
hoverElevation: 3,
padding: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12.0), // Marge intérieure de la carte.
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Affichage de l'en-tête de l'événement.
EventHeader(
creatorFirstName: widget.event.creatorFirstName,
creatorLastName: widget.event.creatorLastName,
profileImageUrl: widget.event.profileImageUrl,
eventDate: widget.event.startDate,
imageUrl: widget.event.imageUrl,
menuKey: menuKey,
menuContext: context,
location: widget.event.location,
onClose: () {
_logger.i("Menu de fermeture actionné pour l'événement ${widget.event.id}");
},
),
const Divider(color: Colors.white24), // Ligne de séparation visuelle.
_buildHeader(menuKey),
const SizedBox(height: DesignSystem.spacingMd),
Row(
children: [
const Spacer(), // Pousse le badge de statut à droite.
EventStatusBadge(status: widget.status), // Badge de statut.
Expanded(child: _buildTitle(theme)),
const SizedBox(width: DesignSystem.spacingSm),
_buildStatusBadge(theme),
],
),
Text(
widget.event.title, // Titre de l'événement.
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold),
),
const SizedBox(height: 5), // Espacement entre le titre et la description.
GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded; // Change l'état d'expansion.
});
_logger.i("Changement d'état d'expansion pour la description de l'événement ${widget.event.id}");
},
child: Text(
_isExpanded || !shouldTruncate
? descriptionText
: "${descriptionText.substring(0, _descriptionThreshold)}...",
style: const TextStyle(color: Colors.white70, fontSize: 14),
maxLines: _isExpanded ? null : 3,
overflow: _isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
),
if (shouldTruncate) // Bouton "Afficher plus" si la description est longue.
GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
});
_logger.i("Affichage de la description complète de l'événement ${widget.event.id}");
},
child: Text(
_isExpanded ? "Afficher moins" : "Afficher plus",
style: const TextStyle(
color: Colors.blue,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 10), // Espacement avant l'image.
EventImage(imageUrl: widget.event.imageUrl), // Affichage de l'image de l'événement.
const Divider(color: Colors.white24), // Nouvelle ligne de séparation.
// Rangée pour les interactions de l'événement (réagir, commenter, partager).
EventInteractionRow(
onReact: widget.onReact,
onComment: widget.onComment,
onShare: widget.onShare,
reactionsCount: widget.event.reactionsCount,
commentsCount: widget.event.commentsCount,
sharesCount: widget.event.sharesCount,
),
const SizedBox(height: DesignSystem.spacingSm),
_buildDescription(theme),
if (widget.event.imageUrl != null) ...[
const SizedBox(height: DesignSystem.spacingMd),
_buildImage(theme),
],
const SizedBox(height: DesignSystem.spacingMd),
Divider(height: 1, color: theme.dividerColor.withOpacity(0.5)),
const SizedBox(height: DesignSystem.spacingSm),
_buildInteractions(theme),
],
),
),
),
);
}
}
// ============================================================================
// WIDGETS
// ============================================================================
/// Construit l'en-tête de l'événement.
Widget _buildHeader(GlobalKey menuKey) {
return EventHeader(
creatorFirstName: widget.event.creatorFirstName,
creatorLastName: widget.event.creatorLastName,
profileImageUrl: widget.event.profileImageUrl,
eventDate: widget.event.startDate,
imageUrl: widget.event.imageUrl,
menuKey: menuKey,
menuContext: context,
location: widget.event.location,
onClose: () {
if (EnvConfig.enableDetailedLogs) {
debugPrint('[EventCard] Menu fermé pour ${widget.event.id}');
}
},
);
}
/// Construit le badge de statut.
Widget _buildStatusBadge(ThemeData theme) {
return EventStatusBadge(status: widget.status);
}
/// Construit le titre.
Widget _buildTitle(ThemeData theme) {
return Text(
widget.event.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
/// Construit la description avec expansion.
Widget _buildDescription(ThemeData theme) {
final description = widget.event.description;
final displayText = _isDescriptionExpanded || !_shouldTruncateDescription
? description
: '${description.substring(0, _descriptionThreshold)}...';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.65),
fontSize: 13,
height: 1.4,
),
maxLines: _isDescriptionExpanded ? null : 2,
overflow: _isDescriptionExpanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
if (_shouldTruncateDescription) ...[
const SizedBox(height: 2),
GestureDetector(
onTap: _toggleDescription,
child: Text(
_isDescriptionExpanded ? 'Voir moins' : 'Voir plus',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
),
],
],
);
}
/// Construit l'image de l'événement.
Widget _buildImage(ThemeData theme) {
return EventImage(
imageUrl: widget.event.imageUrl,
heroTag: 'event_image_${widget.event.id}',
eventTitle: widget.event.title,
);
}
/// Construit les interactions.
Widget _buildInteractions(ThemeData theme) {
return EventInteractionRow(
onReact: widget.onReact,
onComment: widget.onComment,
onShare: widget.onShare,
reactionsCount: widget.event.reactionsCount,
commentsCount: widget.event.commentsCount,
sharesCount: widget.event.sharesCount,
);
}
/// Construit l'arrière-plan du swipe.
Widget _buildSwipeBackground() {
return SwipeBackground(
color: _isClosed ? Colors.green : Colors.red,
icon: _isClosed ? Icons.lock_open : Icons.lock,
label: _isClosed ? 'Rouvrir' : 'Fermer',
);
}
// ============================================================================
// ACTIONS
// ============================================================================
/// Bascule l'expansion de la description.
void _toggleDescription() {
setState(() {
_isDescriptionExpanded = !_isDescriptionExpanded;
});
}
/// Gère le swipe pour fermer/rouvrir.
void _handleDismiss(DismissDirection direction) {
if (_isClosed) {
widget.onReopenEvent();
} else {
widget.onCloseEvent();
widget.onRemoveEvent(widget.event.id);
}
}
}

View File

@@ -1,166 +1,360 @@
import 'package:afterwork/presentation/screens/event/event_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../../../core/constants/design_system.dart';
import '../../../core/constants/env_config.dart';
import '../../../core/theme/theme_provider.dart';
import '../../../core/utils/page_transitions.dart';
import '../../../data/datasources/event_remote_data_source.dart';
import '../../state_management/event_bloc.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/custom_button.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/modern_empty_state.dart';
import '../../widgets/shimmer_loading.dart';
import '../dialogs/add_event_dialog.dart';
import 'event_card.dart';
/// Écran principal des événements, affichant une liste d'événements.
/// Écran principal des événements avec design moderne et compact.
///
/// Cet écran affiche une liste d'événements avec gestion d'états améliorée,
/// animations fluides, et interface utilisateur optimisée.
///
/// **Fonctionnalités:**
/// - Affichage de la liste des événements
/// - Création d'événements
/// - Actions sur les événements (réaction, participation, etc.)
/// - Gestion des états (chargement, erreur, vide)
/// - Pull-to-refresh
class EventScreen extends StatefulWidget {
const EventScreen({
required this.userId,
required this.userFirstName,
required this.userLastName,
required this.profileImageUrl,
super.key,
});
final String userId;
final String userFirstName;
final String userLastName;
final String profileImageUrl;
const EventScreen({
Key? key,
required this.userId,
required this.userFirstName,
required this.userLastName,
required this.profileImageUrl,
}) : super(key: key);
@override
_EventScreenState createState() => _EventScreenState();
State<EventScreen> createState() => _EventScreenState();
}
class _EventScreenState extends State<EventScreen> {
late final EventRemoteDataSource _eventRemoteDataSource;
@override
void initState() {
super.initState();
// Charger les événements lors de l'initialisation
_eventRemoteDataSource = EventRemoteDataSource(http.Client());
_loadEvents();
}
/// Charge les événements au démarrage.
void _loadEvents() {
context.read<EventBloc>().add(LoadEvents(widget.userId));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text(
'Événements',
style: TextStyle(
color: Color(0xFF1DBF73), // Définit la couleur verte du texte
),
),
backgroundColor: const Color(0xFF1E1E2C),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline,
size: 28, color: Color(0xFF1DBF73)),
onPressed: () {
// Naviguer vers une nouvelle page pour ajouter un événement
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddEventPage(
userId: widget.userId,
userFirstName: widget.userFirstName,
userLastName: widget.userLastName,
),
),
);
},
),
],
),
appBar: _buildAppBar(theme),
body: BlocBuilder<EventBloc, EventState>(
builder: (context, state) {
if (state is EventLoading) {
print('[LOG] Chargement en cours des événements...');
return const Center(child: CircularProgressIndicator());
return _buildLoadingState(theme);
} else if (state is EventLoaded) {
final events = state.events;
print('[LOG] Nombre d\'événements à afficher: ${events.length}');
if (events.isEmpty) {
return const Center(child: Text('Aucun événement disponible.'));
}
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
print('[LOG] Affichage de l\'événement $index : ${event.title}');
return EventCard(
key: ValueKey(event.id),
event: event,
userId: widget.userId,
userFirstName: widget.userFirstName,
userLastName: widget.userLastName,
profileImageUrl: widget.profileImageUrl,
onReact: () => _onReact(event.id),
onComment: () => _onComment(event.id),
onShare: () => _onShare(event.id),
onParticipate: () => _onParticipate(event.id),
onCloseEvent: () => _onCloseEvent(event.id),
onReopenEvent: () => _onReopenEvent(event.id),
onRemoveEvent: (String eventId) {
// Retirer l'événement localement après la fermeture
setState(() {
// Logique pour retirer l'événement de la liste localement
// Par exemple, vous pouvez appeler le bloc ou mettre à jour l'état local ici
context.read<EventBloc>().add(RemoveEvent(eventId));
});
},
status: event.status,
);
},
);
return _buildLoadedState(context, theme, state);
} else if (state is EventError) {
print('[ERROR] Message d\'erreur: ${state.message}');
return Center(child: Text('Erreur: ${state.message}'));
return _buildErrorState(context, theme, state.message);
}
return const Center(child: Text('Aucun événement disponible.'));
return _buildEmptyState(context, theme);
},
),
backgroundColor: const Color(0xFF1E1E2C),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Recharger les événements
context.read<EventBloc>().add(LoadEvents(widget.userId));
floatingActionButton: _buildFloatingActionButton(context, theme),
);
}
// ============================================================================
// APP BAR
// ============================================================================
/// Construit la barre d'application.
PreferredSizeWidget _buildAppBar(ThemeData theme) {
return AppBar(
elevation: 0,
scrolledUnderElevation: 2,
title: Text(
'Événements',
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 événement',
onPressed: _navigateToCreateEvent,
),
],
);
}
// ============================================================================
// ÉTATS
// ============================================================================
/// Construit l'état de chargement avec skeleton loaders.
Widget _buildLoadingState(ThemeData theme) {
return SkeletonList(
itemCount: 3,
skeletonWidget: const EventCardSkeleton(),
);
}
/// Construit l'état avec événements chargés.
Widget _buildLoadedState(
BuildContext context,
ThemeData theme,
EventLoaded state,
) {
final events = state.events;
if (events.isEmpty) {
return _buildEmptyState(context, theme);
}
return RefreshIndicator(
onRefresh: _handleRefresh,
color: theme.colorScheme.primary,
child: ListView.separated(
padding: DesignSystem.paddingAll(DesignSystem.spacingLg),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: events.length,
separatorBuilder: (context, index) =>
const SizedBox(height: DesignSystem.spacingMd),
itemBuilder: (context, index) {
final event = events[index];
return FadeInWidget(
delay: Duration(milliseconds: index * 50),
child: EventCard(
key: ValueKey(event.id),
event: event,
userId: widget.userId,
userFirstName: widget.userFirstName,
userLastName: widget.userLastName,
profileImageUrl: widget.profileImageUrl,
onReact: () => _handleReact(event.id),
onComment: () => _handleComment(event.id),
onShare: () => _handleShare(event.id),
onParticipate: () => _handleParticipate(event.id),
onCloseEvent: () => _handleCloseEvent(event.id),
onReopenEvent: () => _handleReopenEvent(event.id),
onRemoveEvent: (String eventId) => _handleRemoveEvent(eventId),
status: event.status,
),
);
},
backgroundColor: const Color(0xFF1DBF73),
child: const Icon(Icons.refresh),
),
);
}
void _onReact(String eventId) {
print('Réaction à l\'événement $eventId');
// Implémentez la logique pour réagir à un événement ici
/// Construit l'état vide.
Widget _buildEmptyState(BuildContext context, ThemeData theme) {
return RefreshIndicator(
onRefresh: _handleRefresh,
color: theme.colorScheme.primary,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height - 200,
child: ModernEmptyState(
illustration: EmptyStateIllustration.events,
title: 'Aucun événement disponible',
description: 'Créez votre premier événement et commencez à organiser des moments inoubliables avec vos amis',
actionLabel: 'Créer un événement',
onAction: _navigateToCreateEvent,
),
),
),
);
}
void _onComment(String eventId) {
print('Commentaire sur l\'événement $eventId');
// Implémentez la logique pour commenter un événement ici
/// Construit l'état d'erreur.
Widget _buildErrorState(
BuildContext context,
ThemeData theme,
String message,
) {
return RefreshIndicator(
onRefresh: _handleRefresh,
color: theme.colorScheme.primary,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height - 200,
child: Center(
child: Padding(
padding: DesignSystem.paddingAll(DesignSystem.spacingXl),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 56,
color: theme.colorScheme.error.withOpacity(0.7),
),
const SizedBox(height: DesignSystem.spacingLg),
Text(
'Erreur',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
textAlign: TextAlign.center,
),
const SizedBox(height: DesignSystem.spacingSm),
Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: DesignSystem.spacingXl),
CustomButton(
text: 'Réessayer',
icon: Icons.refresh_rounded,
onPressed: _loadEvents,
variant: ButtonVariant.outlined,
size: ButtonSize.medium,
),
],
),
),
),
),
),
);
}
void _onShare(String eventId) {
print('Partage de l\'événement $eventId');
// Implémentez la logique pour partager un événement ici
// ============================================================================
// FLOATING ACTION BUTTON
// ============================================================================
/// Construit le bouton flottant (compact).
Widget _buildFloatingActionButton(BuildContext context, ThemeData theme) {
return FloatingActionButton(
onPressed: _navigateToCreateEvent,
tooltip: 'Créer un événement',
elevation: 2,
child: const Icon(Icons.add_rounded, size: 26),
);
}
void _onParticipate(String eventId) {
print('Participation à l\'événement $eventId');
// Implémentez la logique pour participer à un événement ici
// ============================================================================
// ACTIONS
// ============================================================================
/// Gère la recherche.
void _handleSearch() {
context.showInfo('Recherche à venir');
}
void _onCloseEvent(String eventId) {
print('Fermeture de l\'événement $eventId');
// Appeler le bloc pour fermer l'événement sans recharger la liste entière.
/// Navigue vers la création d'événement avec animation.
void _navigateToCreateEvent() {
context.pushSlideUp(
AddEventPage(
userId: widget.userId,
userFirstName: widget.userFirstName,
userLastName: widget.userLastName,
),
);
}
/// Gère le rafraîchissement.
Future<void> _handleRefresh() async {
context.read<EventBloc>().add(LoadEvents(widget.userId));
await Future.delayed(const Duration(milliseconds: 500));
}
/// Gère la réaction à un événement.
Future<void> _handleReact(String eventId) async {
try {
await _eventRemoteDataSource.reactToEvent(eventId, widget.userId);
if (mounted) {
context.showSuccess('Réaction enregistrée');
// Recharger les événements pour mettre à jour les compteurs
context.read<EventBloc>().add(LoadEvents(widget.userId));
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la réaction: ${e.toString()}');
}
if (EnvConfig.enableDetailedLogs) {
debugPrint('[EventScreen] Erreur réaction: $e');
}
}
}
/// Gère le commentaire sur un événement.
void _handleComment(String eventId) {
context.showInfo('Fonctionnalité de commentaires à venir');
}
/// Gère le partage d'un événement.
void _handleShare(String eventId) {
context.showInfo('Fonctionnalité de partage à venir');
}
/// Gère la participation à un événement.
Future<void> _handleParticipate(String eventId) async {
try {
await _eventRemoteDataSource.participateInEvent(eventId, widget.userId);
if (mounted) {
context.showSuccess('Participation enregistrée');
// Recharger les événements pour mettre à jour les participants
context.read<EventBloc>().add(LoadEvents(widget.userId));
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la participation: ${e.toString()}');
}
if (EnvConfig.enableDetailedLogs) {
debugPrint('[EventScreen] Erreur participation: $e');
}
}
}
/// Gère la fermeture d'un événement.
void _handleCloseEvent(String eventId) {
context.read<EventBloc>().add(CloseEvent(eventId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('L\'événement a été fermé avec succès.')),
);
context.showSuccess('L\'événement a été fermé avec succès');
}
void _onReopenEvent(String eventId) {
print('Réouverture de l\'événement $eventId');
// Appeler le bloc pour rouvrir l'événement sans recharger la liste entière.
/// Gère la réouverture d'un événement.
void _handleReopenEvent(String eventId) {
context.read<EventBloc>().add(ReopenEvent(eventId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('L\'événement a été rouvert avec succès.')),
);
context.showSuccess('L\'événement a été rouvert avec succès');
}
/// Gère la suppression d'un événement.
void _handleRemoveEvent(String eventId) {
context.read<EventBloc>().add(RemoveEvent(eventId));
}
}

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/design_system.dart';
import '../../../core/utils/page_transitions.dart';
import '../../../data/providers/friends_provider.dart';
import '../../../domain/entities/friend_request.dart';
import '../../widgets/add_friend_dialog.dart';
import '../../widgets/cards/friend_card.dart';
import '../../widgets/friend_request_card.dart';
import '../../widgets/search_friends.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/friends_tab.dart';
import '../../widgets/requests_tab.dart';
/// Écran principal pour afficher et gérer la liste des amis.
///
@@ -19,6 +21,7 @@ import '../../widgets/search_friends.dart';
/// - Pagination automatique
/// - Pull-to-refresh
/// - Ajout d'amis
/// - Gestion des demandes d'amitié (reçues et envoyées)
class FriendsScreen extends StatefulWidget {
const FriendsScreen({required this.userId, super.key});
@@ -28,76 +31,91 @@ class FriendsScreen extends StatefulWidget {
State<FriendsScreen> createState() => _FriendsScreenState();
}
class _FriendsScreenState extends State<FriendsScreen> with SingleTickerProviderStateMixin {
class _FriendsScreenState extends State<FriendsScreen>
with SingleTickerProviderStateMixin {
// ============================================================================
// CONSTANTS
// ============================================================================
static const double _scrollThreshold = 200;
static const Duration _refreshDelay = Duration(milliseconds: 500);
// ============================================================================
// CONTROLLERS
// ============================================================================
late ScrollController _scrollController;
late TabController _tabController;
late final ScrollController _scrollController;
late final TabController _tabController;
// ============================================================================
// LIFECYCLE
// ============================================================================
@override
void initState() {
super.initState();
_initializeScrollController();
_tabController = TabController(length: 2, vsync: this);
_loadFriends();
_loadSentRequests();
_loadReceivedRequests();
}
void _initializeScrollController() {
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
_initializeControllers();
_loadInitialData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_tabController.dispose();
_disposeControllers();
super.dispose();
}
// ============================================================================
// ACTIONS
// INITIALIZATION
// ============================================================================
/// Charge les amis au démarrage.
void _loadFriends() {
Provider.of<FriendsProvider>(context, listen: false)
.fetchFriends(widget.userId);
/// Initialise les contrôleurs.
void _initializeControllers() {
_scrollController = ScrollController()..addListener(_onScroll);
_tabController = TabController(length: 2, vsync: this);
}
/// Charge les demandes envoyées au démarrage.
void _loadSentRequests() {
Provider.of<FriendsProvider>(context, listen: false)
.fetchSentRequests();
/// Libère les ressources des contrôleurs.
void _disposeControllers() {
_scrollController
..removeListener(_onScroll)
..dispose();
_tabController.dispose();
}
/// Charge les demandes reçues au démarrage.
void _loadReceivedRequests() {
Provider.of<FriendsProvider>(context, listen: false)
.fetchReceivedRequests();
/// Charge les données initiales.
void _loadInitialData() {
final provider = Provider.of<FriendsProvider>(context, listen: false);
provider
..fetchFriends(widget.userId)
..fetchSentRequests()
..fetchReceivedRequests();
}
// ============================================================================
// SCROLL HANDLING
// ============================================================================
/// Gère le défilement pour la pagination.
void _onScroll() {
final provider = Provider.of<FriendsProvider>(context, listen: false);
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
_scrollController.position.maxScrollExtent - _scrollThreshold &&
!provider.isLoading &&
provider.hasMore) {
provider.fetchFriends(widget.userId, loadMore: true);
}
}
/// Gère le rafraîchissement.
// ============================================================================
// ACTIONS
// ============================================================================
/// Gère le rafraîchissement de la liste des amis.
Future<void> _handleRefresh() async {
final provider = Provider.of<FriendsProvider>(context, listen: false);
provider.fetchFriends(widget.userId);
await Future<void>.delayed(const Duration(milliseconds: 500));
await Future<void>.delayed(_refreshDelay);
}
/// Gère l'ajout d'un ami.
@@ -105,370 +123,174 @@ class _FriendsScreenState extends State<FriendsScreen> with SingleTickerProvider
showDialog<void>(
context: context,
builder: (context) => AddFriendDialog(
onFriendAdded: () {
// Rafraîchir la liste des amis et des demandes après l'ajout
_loadFriends();
_loadSentRequests();
_loadReceivedRequests();
},
onFriendAdded: _onFriendAdded,
),
);
}
/// Callback appelé après l'ajout d'un ami.
void _onFriendAdded() {
final provider = Provider.of<FriendsProvider>(context, listen: false);
provider
..fetchFriends(widget.userId)
..fetchSentRequests()
..fetchReceivedRequests();
}
/// Gère l'actualisation manuelle.
void _handleRefreshAll() {
final provider = Provider.of<FriendsProvider>(context, listen: false);
provider
..fetchFriends(widget.userId)
..fetchSentRequests()
..fetchReceivedRequests();
}
// ============================================================================
// BUILD
// ============================================================================
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: _buildAppBar(theme),
body: _buildBody(theme),
floatingActionButton: _buildFloatingActionButton(theme),
appBar: _buildAppBar(),
body: _buildBody(),
floatingActionButton: _buildFloatingActionButton(),
);
}
/// Construit la barre d'application.
PreferredSizeWidget _buildAppBar(ThemeData theme) {
PreferredSizeWidget _buildAppBar() {
final theme = Theme.of(context);
return AppBar(
title: const Text('Mes Amis'),
elevation: 0,
scrolledUnderElevation: 2,
title: Text(
'Mes Amis',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
bottom: TabBar(
controller: _tabController,
indicatorSize: TabBarIndicatorSize.tab,
tabs: const [
Tab(text: 'Amis', icon: Icon(Icons.people)),
Tab(text: 'Demandes', icon: Icon(Icons.person_add)),
Tab(
text: 'Amis',
icon: Icon(Icons.people_rounded, size: 22),
iconMargin: EdgeInsets.only(bottom: 4),
),
Tab(
text: 'Demandes',
icon: Icon(Icons.person_add_rounded, size: 22),
iconMargin: EdgeInsets.only(bottom: 4),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
icon: const Icon(Icons.refresh_rounded, size: 22),
tooltip: 'Actualiser',
onPressed: () {
_loadFriends();
_loadSentRequests();
_loadReceivedRequests();
},
onPressed: _handleRefreshAll,
),
],
);
}
/// Construit le corps de l'écran.
Widget _buildBody(ThemeData theme) {
Widget _buildBody() {
return SafeArea(
child: TabBarView(
controller: _tabController,
children: [
// Onglet Amis
Column(
children: [
_buildSearchBar(),
Expanded(
child: Consumer<FriendsProvider>(
builder: (context, provider, child) {
if (provider.isLoading && provider.friendsList.isEmpty) {
return _buildLoadingState(theme);
}
if (provider.friendsList.isEmpty) {
return _buildEmptyState(theme);
}
return _buildFriendsList(theme, provider);
},
),
),
],
FriendsTab(
userId: widget.userId,
scrollController: _scrollController,
onRefresh: _handleRefresh,
),
// Onglet Demandes en attente
_buildPendingRequestsTab(theme),
],
),
);
}
/// Construit la barre de recherche.
Widget _buildSearchBar() {
return const Padding(
padding: EdgeInsets.all(16),
child: SearchFriends(),
);
}
/// Construit l'état de chargement.
Widget _buildLoadingState(ThemeData theme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Chargement des amis...',
style: theme.textTheme.bodyMedium,
RequestsTab(
onAccept: _handleAcceptRequest,
onReject: _handleRejectRequest,
onCancel: _handleCancelRequest,
onRefresh: _handleRefreshAll,
),
],
),
);
}
/// Construit l'état vide.
Widget _buildEmptyState(ThemeData theme) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outline,
size: 64,
color: theme.colorScheme.secondary.withOpacity(0.6),
),
const SizedBox(height: 24),
Text(
'Aucun ami trouvé',
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Commencez à ajouter des amis pour voir leurs événements',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Construit la liste des amis.
Widget _buildFriendsList(ThemeData theme, FriendsProvider provider) {
return RefreshIndicator(
onRefresh: _handleRefresh,
color: theme.colorScheme.primary,
child: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
),
itemCount: provider.friendsList.length,
itemBuilder: (context, index) {
final friend = provider.friendsList[index];
return FriendCard(friend: friend);
},
),
);
}
/// Construit le bouton flottant (compact).
Widget _buildFloatingActionButton(ThemeData theme) {
/// Construit le bouton flottant.
Widget _buildFloatingActionButton() {
return FloatingActionButton(
onPressed: _handleAddFriend,
tooltip: 'Ajouter un ami',
child: const Icon(Icons.person_add),
elevation: 2,
child: const Icon(Icons.person_add_rounded, size: 24),
);
}
/// Construit l'onglet des demandes en attente avec deux sections.
Widget _buildPendingRequestsTab(ThemeData theme) {
return Consumer<FriendsProvider>(
builder: (context, provider, child) {
final isLoading = provider.isLoadingReceivedRequests || provider.isLoadingSentRequests;
final hasReceived = provider.receivedRequests.isNotEmpty;
final hasSent = provider.sentRequests.isNotEmpty;
if (isLoading && !hasReceived && !hasSent) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Chargement des demandes...',
style: theme.textTheme.bodyMedium,
),
],
),
);
}
if (!hasReceived && !hasSent) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.person_add_disabled,
size: 64,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Aucune demande en attente',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 8),
Text(
'Les demandes d\'amitié apparaîtront ici',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await Future.wait([
provider.fetchReceivedRequests(),
provider.fetchSentRequests(),
]);
},
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
// Section Demandes reçues
if (hasReceived) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Demandes reçues',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...provider.receivedRequests.map((request) => FriendRequestCard(
request: request,
onAccept: () => _handleAcceptRequest(provider, request.friendshipId),
onReject: () => _handleRejectRequest(provider, request.friendshipId),
)),
const SizedBox(height: 16),
],
// Section Demandes envoyées
if (hasSent) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Demandes envoyées',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...provider.sentRequests.map((request) => FriendRequestCard(
request: request,
onAccept: null, // Pas d'accepter pour les demandes envoyées
onReject: () => _handleCancelRequest(provider, request.friendshipId),
isSentRequest: true, // Indique que c'est une demande envoyée
)),
],
],
),
);
},
);
}
// ============================================================================
// REQUEST HANDLERS
// ============================================================================
/// Gère l'acceptation d'une demande d'amitié.
Future<void> _handleAcceptRequest(FriendsProvider provider, String friendshipId) async {
Future<void> _handleAcceptRequest(
FriendsProvider provider,
String friendshipId,
) async {
try {
await provider.acceptFriendRequest(friendshipId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Demande d\'amitié acceptée'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
),
);
// Rafraîchir les deux onglets
_loadFriends();
_loadReceivedRequests();
}
if (!mounted) return;
context.showSuccess('Demande d\'amitié acceptée');
_refreshAfterRequest();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
if (!mounted) return;
context.showError('Erreur: ${e.toString()}');
}
}
/// Gère le rejet d'une demande d'amitié.
Future<void> _handleRejectRequest(FriendsProvider provider, String friendshipId) async {
Future<void> _handleRejectRequest(
FriendsProvider provider,
String friendshipId,
) async {
try {
await provider.rejectFriendRequest(friendshipId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Demande d\'amitié rejetée'),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
),
);
_loadReceivedRequests();
}
if (!mounted) return;
context.showWarning('Demande d\'amitié rejetée');
provider.fetchReceivedRequests();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
if (!mounted) return;
context.showError('Erreur: ${e.toString()}');
}
}
/// Gère l'annulation d'une demande d'amitié envoyée.
Future<void> _handleCancelRequest(FriendsProvider provider, String friendshipId) async {
Future<void> _handleCancelRequest(
FriendsProvider provider,
String friendshipId,
) async {
try {
await provider.cancelFriendRequest(friendshipId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Demande d\'amitié annulée'),
backgroundColor: Colors.blue,
behavior: SnackBarBehavior.floating,
),
);
_loadSentRequests();
}
if (!mounted) return;
context.showInfo('Demande d\'amitié annulée');
provider.fetchSentRequests();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
if (!mounted) return;
context.showError('Erreur: ${e.toString()}');
}
}
// ============================================================================
// HELPERS
// ============================================================================
/// Rafraîchit les données après une action sur une demande.
void _refreshAfterRequest() {
final provider = Provider.of<FriendsProvider>(context, listen: false);
provider
..fetchFriends(widget.userId)
..fetchReceivedRequests();
}
}

View File

@@ -10,6 +10,8 @@ import '../../widgets/search_friends.dart';
/// Écran d'affichage des amis avec gestion des amis via un provider.
class FriendsScreenWithProvider extends StatelessWidget {
FriendsScreenWithProvider({super.key});
final Logger _logger = Logger(); // Logger pour la traçabilité détaillée.
@override
@@ -21,7 +23,7 @@ class FriendsScreenWithProvider extends StatelessWidget {
child: Column(
children: [
const Padding(
padding: EdgeInsets.all(8.0),
padding: EdgeInsets.all(8),
child: SearchFriends(), // Barre de recherche pour trouver des amis.
),
Expanded(
@@ -30,7 +32,7 @@ class FriendsScreenWithProvider extends StatelessWidget {
final friends = friendsProvider.friendsList;
if (friends.isEmpty) {
_logger.i("[LOG] Aucun ami trouvé."); // Log pour la recherche sans résultat.
_logger.i('[LOG] Aucun ami trouvé.'); // Log pour la recherche sans résultat.
return const Center(
child: Text(
'Aucun ami trouvé', // Message affiché si aucun ami n'est trouvé.
@@ -60,7 +62,7 @@ class FriendsScreenWithProvider extends StatelessWidget {
_logger.i("[LOG] Suppression de l'ami avec l'ID : ${friend.friendId}");
friendsProvider.removeFriend(friend.friendId); // Suppression de l'ami via le provider.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Ami supprimé : ${friend.friendFirstName}")),
SnackBar(content: Text('Ami supprimé : ${friend.friendFirstName}')),
);
},
child: FriendExpandingCard(
@@ -104,6 +106,6 @@ class FriendsScreenWithProvider extends StatelessWidget {
lastInteraction: friend.lastInteraction ?? 'Aucune',
dateAdded: friend.dateAdded ?? 'Inconnu',
),
));
),);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/colors.dart';
import '../../../core/theme/theme_provider.dart';
import '../../../core/utils/app_logger.dart';
import '../../widgets/friend_suggestions.dart';
import '../../widgets/group_list.dart';
import '../../widgets/popular_activity_list.dart';
@@ -21,10 +22,10 @@ class HomeContentScreen extends StatelessWidget {
final themeProvider = Provider.of<ThemeProvider>(context);
// Obtention des dimensions de l'écran pour adapter la mise en page
final size = MediaQuery.of(context).size;
print("Chargement de HomeContentScreen avec le thème actuel");
AppLogger.d('Chargement de HomeContentScreen avec le thème actuel', tag: 'HomeContentScreen');
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 15.0),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -116,12 +117,12 @@ class HomeContentScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Suggestions damis',
title: 'Suggestions d\'amis',
icon: Icons.person_add,
textStyle: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 10),
FriendSuggestions(size: size),
const FriendSuggestions(maxSuggestions: 5),
],
),
),
@@ -133,13 +134,13 @@ class HomeContentScreen extends StatelessWidget {
/// Crée la carte de bienvenue, en utilisant les couleurs dynamiques en fonction du thème sélectionné.
/// [themeProvider] fournit l'état actuel du thème pour adapter les couleurs.
Widget _buildWelcomeCard(ThemeProvider themeProvider) {
print("Création de la carte de bienvenue avec le thème actuel");
AppLogger.d('Création de la carte de bienvenue avec le thème actuel', tag: 'HomeContentScreen');
return Card(
elevation: 5,
color: themeProvider.isDarkMode ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -165,13 +166,13 @@ class HomeContentScreen extends StatelessWidget {
required ThemeProvider themeProvider,
required Widget child,
}) {
print("Création d'une carte de section avec le thème actuel");
AppLogger.d("Création d'une carte de section avec le thème actuel", tag: 'HomeContentScreen');
return Card(
elevation: 3,
color: themeProvider.isDarkMode ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.all(12.0),
padding: const EdgeInsets.all(12),
child: child,
),
);

View File

@@ -1,217 +1,325 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:afterwork/presentation/screens/event/event_screen.dart';
import 'package:afterwork/presentation/screens/profile/profile_screen.dart';
import 'package:afterwork/presentation/screens/social/social_screen.dart';
import 'package:afterwork/presentation/screens/establishments/establishments_screen.dart';
import 'package:afterwork/presentation/screens/home/home_content.dart';
import 'package:afterwork/data/datasources/event_remote_data_source.dart';
import 'package:afterwork/presentation/screens/notifications/notifications_screen.dart';
import '../../../core/constants/colors.dart';
import '../../../core/theme/theme_provider.dart';
import '../friends/friends_screen.dart';
import '../../../core/constants/design_system.dart';
import '../../../core/theme/theme_provider.dart';
import '../../../core/utils/page_transitions.dart';
import '../../../data/datasources/event_remote_data_source.dart';
import '../../../data/services/notification_service.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/notification_badge.dart';
import '../chat/conversations_screen.dart';
import '../establishments/establishments_screen.dart';
import '../event/event_screen.dart';
import '../friends/friends_screen.dart';
import '../notifications/notifications_screen.dart';
import '../profile/profile_screen.dart';
import '../social/social_screen.dart';
import 'home_content.dart';
/// Écran principal de l'application avec navigation moderne.
class HomeScreen extends StatefulWidget {
const HomeScreen({
required this.eventRemoteDataSource,
required this.userId,
required this.userFirstName,
required this.userLastName,
required this.userProfileImage,
super.key,
});
final EventRemoteDataSource eventRemoteDataSource;
final String userId;
final String userFirstName;
final String userLastName;
final String userProfileImage;
const HomeScreen({
Key? key,
required this.eventRemoteDataSource,
required this.userId,
required this.userFirstName,
required this.userLastName,
required this.userProfileImage,
}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
late final List<Widget> _screens;
@override
void initState() {
super.initState();
_tabController = TabController(length: 6, vsync: this);
_screens = [
const HomeContentScreen(),
EventScreen(
userId: widget.userId,
userFirstName: widget.userFirstName,
userLastName: widget.userLastName,
profileImageUrl: widget.userProfileImage,
),
const SocialScreen(),
FriendsScreen(userId: widget.userId),
const ProfileScreen(),
];
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _onMenuSelected(BuildContext context, String option) {
print('$option sélectionné'); // Log pour chaque option
void _onTabTapped(int index) {
setState(() {
_currentIndex = index;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final themeProvider = Provider.of<ThemeProvider>(context);
return Scaffold(
backgroundColor: AppColors.backgroundColor,
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
floating: true,
pinned: true,
snap: true,
elevation: 2,
backgroundColor: themeProvider.currentTheme.primaryColor,
leading: Padding(
padding: const EdgeInsets.all(4.0),
child: Image.asset(
'lib/assets/images/logo.png',
height: 40,
appBar: _buildModernAppBar(context, theme, themeProvider),
body: IndexedStack(
index: _currentIndex,
children: _screens,
),
bottomNavigationBar: _buildBottomNavBar(theme),
);
}
/// AppBar moderne et épurée
PreferredSizeWidget _buildModernAppBar(
BuildContext context,
ThemeData theme,
ThemeProvider themeProvider,
) {
return AppBar(
elevation: 0,
scrolledUnderElevation: 2,
centerTitle: false,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'lib/assets/images/logo.png',
height: 32,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.event_available,
size: 28,
color: theme.colorScheme.primary,
);
},
),
const SizedBox(width: DesignSystem.spacingSm),
Text(
'Afterwork',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
],
),
actions: [
// Recherche
IconButton(
icon: const Icon(Icons.search_rounded, size: 22),
tooltip: 'Rechercher',
onPressed: () {
context.showInfo('Recherche à venir');
},
),
// Messages
IconButton(
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 22),
tooltip: 'Messages',
onPressed: () {
context.pushFadeScale(const ConversationsScreen());
},
),
// Notifications avec badge
Consumer<NotificationService>(
builder: (context, notificationService, child) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: NotificationBadge(
count: notificationService.unreadCount,
child: IconButton(
icon: const Icon(Icons.notifications_none_rounded, size: 22),
tooltip: 'Notifications',
onPressed: () {
context.pushFadeScale(const NotificationsScreen());
},
),
),
actions: [
_buildActionIcon(Icons.add, 'Publier', context),
_buildActionIcon(Icons.search, 'Rechercher', context),
_buildActionIcon(Icons.message, 'Message', context),
_buildNotificationsIcon(context, 105),
Switch(
value: themeProvider.isDarkMode,
onChanged: (value) {
themeProvider.toggleTheme();
},
activeColor: AppColors.accentColor,
),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: AppColors.lightPrimary,
labelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
unselectedLabelStyle: const TextStyle(fontSize: 11),
labelColor: themeProvider.isDarkMode ? AppColors.darkOnPrimary : AppColors.lightOnPrimary,
unselectedLabelColor: themeProvider.isDarkMode ? AppColors.darkIconSecondary : AppColors.lightIconSecondary,
tabs: [
const Tab(icon: Icon(Icons.home, size: 24), text: 'Accueil'),
const Tab(icon: Icon(Icons.event, size: 24), text: 'Événements'),
const Tab(icon: Icon(Icons.location_city, size: 24), text: 'Établissements'),
const Tab(icon: Icon(Icons.people, size: 24), text: 'Social'),
const Tab(icon: Icon(Icons.people_alt_outlined, size: 24), text: 'Ami(e)s'),
_buildProfileTab(),
);
},
),
// Menu établissements
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert_rounded, size: 22),
offset: const Offset(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
onSelected: (value) {
switch (value) {
case 'establishments':
context.pushFadeScale(const EstablishmentsScreen());
break;
case 'theme':
themeProvider.toggleTheme();
break;
case 'settings':
context.showInfo('Paramètres à venir');
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'establishments',
child: Row(
children: [
Icon(
Icons.location_city_rounded,
size: 20,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: DesignSystem.spacingSm),
const Text('Établissements'),
],
),
),
];
},
body: TabBarView(
controller: _tabController,
children: [
const HomeContentScreen(),
EventScreen(
userId: widget.userId,
userFirstName: widget.userFirstName,
userLastName: widget.userLastName,
profileImageUrl: widget.userProfileImage,
const PopupMenuDivider(),
PopupMenuItem(
value: 'theme',
child: Row(
children: [
Icon(
themeProvider.isDarkMode
? Icons.light_mode_rounded
: Icons.dark_mode_rounded,
size: 20,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: DesignSystem.spacingSm),
Text(themeProvider.isDarkMode ? 'Mode clair' : 'Mode sombre'),
],
),
),
PopupMenuItem(
value: 'settings',
child: Row(
children: [
Icon(
Icons.settings_rounded,
size: 20,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: DesignSystem.spacingSm),
const Text('Paramètres'),
],
),
),
const EstablishmentsScreen(),
const SocialScreen(),
FriendsScreen(userId: widget.userId),
const ProfileScreen(),
],
),
),
const SizedBox(width: 4),
],
);
}
Tab _buildProfileTab() {
return Tab(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.secondary,
width: 2.0,
/// BottomNavigationBar moderne
Widget _buildBottomNavBar(ThemeData theme) {
return Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
),
child: CircleAvatar(
radius: 16,
backgroundColor: AppColors.surface,
child: ClipOval(
child: FadeInImage.assetNetwork(
placeholder: 'lib/assets/images/user_placeholder.png',
image: widget.userProfileImage,
fit: BoxFit.cover,
imageErrorBuilder: (context, error, stackTrace) {
return Image.asset('lib/assets/images/profile_picture.png', fit: BoxFit.cover);
},
),
),
),
],
),
);
}
Widget _buildNotificationsIcon(BuildContext context, int notificationCount) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
backgroundColor: AppColors.surface,
radius: 18,
child: IconButton(
icon: Icon(Icons.notifications, color: AppColors.iconPrimary, size: 20),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const NotificationsScreen(),
),
);
},
child: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onTabTapped,
elevation: 0,
height: 64,
labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected,
animationDuration: const Duration(milliseconds: 400),
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined, size: 24),
selectedIcon: Icon(
Icons.home_rounded,
size: 26,
color: theme.colorScheme.primary,
),
label: 'Accueil',
),
if (notificationCount > 0)
Positioned(
right: -6,
top: -6,
NavigationDestination(
icon: const Icon(Icons.event_outlined, size: 24),
selectedIcon: Icon(
Icons.event_rounded,
size: 26,
color: theme.colorScheme.primary,
),
label: 'Événements',
),
NavigationDestination(
icon: const Icon(Icons.explore_outlined, size: 24),
selectedIcon: Icon(
Icons.explore_rounded,
size: 26,
color: theme.colorScheme.primary,
),
label: 'Social',
),
NavigationDestination(
icon: const Icon(Icons.people_outline_rounded, size: 24),
selectedIcon: Icon(
Icons.people_rounded,
size: 26,
color: theme.colorScheme.primary,
),
label: 'Amis',
),
NavigationDestination(
icon: Hero(
tag: 'user_profile_avatar_${widget.userId}',
child: CircleAvatar(
radius: 14,
backgroundImage: widget.userProfileImage.isNotEmpty
? NetworkImage(widget.userProfileImage)
: null,
child: widget.userProfileImage.isEmpty
? const Icon(Icons.person, size: 16)
: null,
),
),
selectedIcon: Hero(
tag: 'user_profile_avatar_${widget.userId}',
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary,
width: 2,
),
),
constraints: BoxConstraints(
minWidth: 18,
minHeight: 18,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
child: CircleAvatar(
radius: 14,
backgroundImage: widget.userProfileImage.isNotEmpty
? NetworkImage(widget.userProfileImage)
: null,
child: widget.userProfileImage.isEmpty
? const Icon(Icons.person, size: 16)
: null,
),
),
),
label: 'Profil',
),
],
),
);
}
Widget _buildActionIcon(IconData iconData, String label, BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: CircleAvatar(
backgroundColor: AppColors.surface,
radius: 18,
child: IconButton(
icon: Icon(iconData, color: AppColors.iconPrimary, size: 20),
onPressed: () {
_onMenuSelected(context, label);
},
),
),
);
}
}

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import '../../../core/utils/app_logger.dart';
/// Écran pour la sélection d'une localisation sur une carte.
/// L'utilisateur peut choisir un lieu en interagissant avec la carte Google Maps.
/// Des logs permettent de tracer les actions comme la sélection et l'affichage de la carte.
class LocationPickerScreen extends StatefulWidget {
const LocationPickerScreen({Key? key}) : super(key: key);
const LocationPickerScreen({super.key});
@override
_LocationPickerScreenState createState() => _LocationPickerScreenState();
@@ -17,7 +19,7 @@ class _LocationPickerScreenState extends State<LocationPickerScreen> {
@override
Widget build(BuildContext context) {
print('Affichage de l\'écran de sélection de localisation.');
AppLogger.d('Affichage de l\'écran de sélection de localisation.', tag: 'LocationPickerScreen');
return Scaffold(
appBar: AppBar(
@@ -34,7 +36,7 @@ class _LocationPickerScreenState extends State<LocationPickerScreen> {
),
onMapCreated: (controller) {
_mapController = controller;
print('Carte Google Maps créée.');
AppLogger.d('Carte Google Maps créée.', tag: 'LocationPickerScreen');
},
onTap: _selectLocation, // Sélection de la localisation sur la carte
markers: {
@@ -46,10 +48,10 @@ class _LocationPickerScreenState extends State<LocationPickerScreen> {
),
),
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () {
print('Lieu sélectionné : $_pickedLocation');
AppLogger.d('Lieu sélectionné : $_pickedLocation', tag: 'LocationPickerScreen');
Navigator.of(context).pop(_pickedLocation);
},
icon: const Icon(Icons.check),
@@ -70,13 +72,13 @@ class _LocationPickerScreenState extends State<LocationPickerScreen> {
setState(() {
_pickedLocation = position;
});
print('Localisation sélectionnée : $_pickedLocation');
AppLogger.d('Localisation sélectionnée : $_pickedLocation', tag: 'LocationPickerScreen');
}
@override
void dispose() {
_mapController.dispose();
print('Libération des ressources de la carte.');
AppLogger.d('Libération des ressources de la carte.', tag: 'LocationPickerScreen');
super.dispose();
}
}

View File

@@ -1,398 +1,578 @@
import 'dart:async';
import 'package:afterwork/data/datasources/user_remote_data_source.dart';
import 'package:afterwork/data/models/user_model.dart';
import 'package:afterwork/data/services/preferences_helper.dart';
import 'package:afterwork/data/services/secure_storage.dart';
import 'package:afterwork/presentation/screens/home/home_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:http/http.dart' as http;
import 'package:loading_icon_button/loading_icon_button.dart';
import 'package:provider/provider.dart';
import '../../../core/errors/exceptions.dart';
import '../../../core/constants/env_config.dart';
import '../../../core/theme/theme_provider.dart';
import '../../../core/utils/validators.dart';
import '../../../data/datasources/event_remote_data_source.dart';
import '../../../data/datasources/user_remote_data_source.dart';
import '../../../data/models/user_model.dart';
import '../../../data/providers/user_provider.dart';
import '../../../data/services/preferences_helper.dart';
import '../../../data/services/secure_storage.dart';
import '../../../domain/entities/user.dart';
import '../../widgets/custom_button.dart';
import '../home/home_screen.dart';
import '../signup/SignUpScreen.dart';
/// L'écran de connexion où les utilisateurs peuvent s'authentifier.
/// Toutes les actions sont loguées pour permettre un suivi dans le terminal et détecter les erreurs.
/// Écran de connexion avec design moderne et compact.
///
/// Cet écran permet aux utilisateurs de s'authentifier avec leur email
/// et mot de passe. Il inclut la validation des champs, la gestion d'erreurs,
/// et une interface utilisateur optimisée.
///
/// **Fonctionnalités:**
/// - Validation des champs en temps réel
/// - Affichage/masquage du mot de passe
/// - Gestion d'erreurs robuste
/// - Support du thème clair/sombre
/// - Navigation vers l'inscription
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
_LoginScreenState createState() => _LoginScreenState();
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen>
with SingleTickerProviderStateMixin {
// Clé globale pour la validation du formulaire
// ============================================================================
// FORMULAIRE
// ============================================================================
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
// Variables pour stocker l'email et le mot de passe saisis par l'utilisateur
String _email = '';
String _password = '';
// ============================================================================
// ÉTATS
// ============================================================================
// États de l'écran
bool _isPasswordVisible = false; // Pour basculer la visibilité du mot de passe
bool _isSubmitting = false; // Indique si la soumission du formulaire est en cours
bool _showErrorMessage = false; // Affiche un message d'erreur si nécessaire
bool _isPasswordVisible = false;
bool _isSubmitting = false;
String? _errorMessage;
// Sources de données et services
final UserRemoteDataSource _userRemoteDataSource = UserRemoteDataSource(http.Client());
final SecureStorage _secureStorage = SecureStorage();
final PreferencesHelper _preferencesHelper = PreferencesHelper();
// ============================================================================
// SERVICES
// ============================================================================
// Contrôleur pour le bouton de chargement
final _btnController = LoadingButtonController();
late final UserRemoteDataSource _userRemoteDataSource;
late final SecureStorage _secureStorage;
late final PreferencesHelper _preferencesHelper;
// ============================================================================
// ANIMATIONS
// ============================================================================
// Contrôleur d'animation pour gérer la transition entre les écrans
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_initializeServices();
_initializeAnimations();
}
void _initializeServices() {
_userRemoteDataSource = UserRemoteDataSource(http.Client());
_secureStorage = SecureStorage();
_preferencesHelper = PreferencesHelper();
}
void _initializeAnimations() {
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
duration: const Duration(milliseconds: 800),
);
debugPrint("[LOG] Contrôleur d'animation initialisé.");
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeIn,
);
_animationController.forward();
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_animationController.dispose();
debugPrint("[LOG] Ressources d'animation libérées.");
super.dispose();
}
/// Bascule la visibilité du mot de passe et logue l'état actuel.
// ============================================================================
// ACTIONS
// ============================================================================
/// Bascule la visibilité du mot de passe.
void _togglePasswordVisibility() {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
debugPrint("[LOG] Visibilité du mot de passe basculée: $_isPasswordVisible");
}
/// Affiche un toast avec le message spécifié et logue l'action.
void _showToast(String message) {
Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: Colors.black,
textColor: Colors.white,
fontSize: 16.0,
);
debugPrint("[LOG] Toast affiché : $message");
}
/// Soumet le formulaire de connexion.
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) {
return;
}
/// Soumet le formulaire de connexion et tente d'authentifier l'utilisateur.
/// Toutes les étapes et erreurs sont loguées pour une traçabilité complète.
Future<void> _submit() async {
debugPrint("[LOG] Tentative de soumission du formulaire de connexion.");
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
if (_formKey.currentState!.validate()) {
setState(() {
_isSubmitting = true;
_showErrorMessage = false;
});
_formKey.currentState!.save(); // Sauvegarde des données saisies
try {
final email = _emailController.text.trim();
final password = _passwordController.text;
try {
_btnController.start();
debugPrint("[LOG] Appel à l'API pour authentifier l'utilisateur.");
final user = await _userRemoteDataSource.authenticateUser(email, password);
final UserModel user = await _userRemoteDataSource.authenticateUser(_email, _password);
if (user.userId.isEmpty) {
throw Exception('ID utilisateur manquant dans la réponse');
}
if (user.userId.isNotEmpty) {
debugPrint("[LOG] Utilisateur authentifié avec succès. ID: ${user.userId}");
await _secureStorage.saveUserId(user.userId);
await _preferencesHelper.saveUserName(user.userLastName);
await _preferencesHelper.saveUserLastName(user.userFirstName);
// Met à jour le `UserProvider` avec les informations utilisateur authentifiées
final userProvider = Provider.of<UserProvider>(context, listen: false);
userProvider.setUser(User(
userId: user.userId,
userLastName: user.userLastName,
userFirstName: user.userFirstName,
email: user.email,
motDePasse: 'motDePasseHashé',
profileImageUrl: 'lib/assets/images/profile_picture.png',
eventsCount: user.eventsCount ?? 0,
friendsCount: user.friendsCount ?? 0,
postsCount: user.postsCount ?? 0,
visitedPlacesCount: user.visitedPlacesCount ?? 0,
));
_showToast("Connexion réussie !");
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => HomeScreen(
userId: user.userId,
userFirstName: user.userFirstName,
userLastName: user.userLastName,
userProfileImage: 'lib/assets/images/profile_picture.png',
eventRemoteDataSource: EventRemoteDataSource(http.Client()),
),
),
);
} else {
debugPrint("[ERROR] L'ID utilisateur est manquant dans la réponse.");
_showToast("Erreur : ID utilisateur manquant.");
}
} catch (e) {
// Gestion des erreurs
debugPrint("[ERROR] Erreur lors de la connexion : $e");
_showToast("Erreur lors de la connexion : ${e.toString()}");
_btnController.error();
setState(() {
_showErrorMessage = true;
});
} finally {
_btnController.reset();
await _saveUserData(user);
await _navigateToHome(user);
} catch (e) {
_handleError(e);
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
} else {
debugPrint("[ERROR] Validation du formulaire échouée.");
_btnController.reset();
_showToast("Veuillez vérifier les informations saisies.");
}
}
/// Sauvegarde les données utilisateur.
Future<void> _saveUserData(UserModel user) async {
await _secureStorage.saveUserId(user.userId);
await _preferencesHelper.saveUserName(user.userLastName);
await _preferencesHelper.saveUserLastName(user.userFirstName);
final userProvider = Provider.of<UserProvider>(context, listen: false);
userProvider.setUser(
User(
userId: user.userId,
userLastName: user.userLastName,
userFirstName: user.userFirstName,
email: user.email,
motDePasse: 'motDePasseHashé',
profileImageUrl: user.profileImageUrl,
eventsCount: user.eventsCount ?? 0,
friendsCount: user.friendsCount ?? 0,
postsCount: user.postsCount ?? 0,
visitedPlacesCount: user.visitedPlacesCount ?? 0,
),
);
}
/// Navigue vers l'écran d'accueil.
Future<void> _navigateToHome(UserModel user) async {
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => HomeScreen(
userId: user.userId,
userFirstName: user.userFirstName,
userLastName: user.userLastName,
userProfileImage: user.profileImageUrl,
eventRemoteDataSource: EventRemoteDataSource(http.Client()),
),
),
);
}
/// Gère les erreurs de connexion.
void _handleError(Object error) {
String message = 'Erreur lors de la connexion';
if (error.toString().contains('Unauthorized') ||
error.toString().contains('incorrect')) {
message = 'Email ou mot de passe incorrect';
} else if (error.toString().contains('network') ||
error.toString().contains('timeout')) {
message = 'Problème de connexion. Vérifiez votre réseau';
} else {
message = error.toString().replaceAll('Exception: ', '');
}
setState(() {
_errorMessage = message;
});
if (EnvConfig.enableDetailedLogs) {
debugPrint('[LoginScreen] Erreur: $error');
}
}
/// Navigue vers l'écran d'inscription.
void _navigateToSignUp() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SignUpScreen(),
),
);
}
/// Gère la réinitialisation du mot de passe.
Future<void> _handleForgotPassword() async {
final emailController = TextEditingController();
final formKey = GlobalKey<FormState>();
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Réinitialisation du mot de passe'),
content: Form(
key: formKey,
child: TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Entrez votre email',
),
validator: Validators.validateEmail,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
if (formKey.currentState!.validate()) {
Navigator.pop(context, true);
}
},
child: const Text('Envoyer'),
),
],
),
);
if (result == true && emailController.text.isNotEmpty) {
try {
await _userRemoteDataSource.requestPasswordReset(
emailController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Un email de réinitialisation a été envoyé à votre adresse',
),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
}
// ============================================================================
// BUILD
// ============================================================================
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final themeProvider = Provider.of<ThemeProvider>(context);
bool isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom != 0;
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
return Scaffold(
body: Stack(
children: [
// Arrière-plan animé
AnimatedContainer(
duration: const Duration(seconds: 3),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.secondary,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
// Spinner de chargement lors de la soumission
if (_isSubmitting)
const Center(
child: SpinKitFadingCircle(
color: Colors.white,
size: 50.0,
),
),
// Icône de changement de thème
Positioned(
top: 40,
right: 20,
child: IconButton(
icon: Icon(
themeProvider.isDarkMode ? Icons.dark_mode : Icons.light_mode,
color: theme.iconTheme.color,
),
onPressed: () {
themeProvider.toggleTheme();
debugPrint("[LOG] Thème basculé : ${themeProvider.isDarkMode ? 'Sombre' : 'Clair'}");
},
),
),
// Formulaire de connexion
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'lib/assets/images/logo.png',
height: size.height * 0.25,
),
const SizedBox(height: 20),
Text(
'Bienvenue sur AfterWork',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 40),
_buildTextFormField(
label: 'Email',
icon: Icons.email,
validator: (value) {
if (value == null || value.isEmpty) {
debugPrint("[ERROR] Champ email vide.");
return 'Veuillez entrer votre email';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
debugPrint("[ERROR] Email invalide.");
return 'Veuillez entrer un email valide';
}
return null;
},
onSaved: (value) {
_email = value!;
debugPrint("[LOG] Email enregistré : $_email");
},
),
const SizedBox(height: 20),
_buildTextFormField(
label: 'Mot de passe',
icon: Icons.lock,
obscureText: !_isPasswordVisible,
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
color: theme.iconTheme.color,
),
onPressed: _togglePasswordVisibility,
),
validator: (value) {
if (value == null || value.isEmpty) {
debugPrint("[ERROR] Champ mot de passe vide.");
return 'Veuillez entrer votre mot de passe';
}
if (value.length < 6) {
debugPrint("[ERROR] Mot de passe trop court.");
return 'Le mot de passe doit comporter au moins 6 caractères';
}
return null;
},
onSaved: (value) {
_password = value!;
debugPrint("[LOG] Mot de passe enregistré.");
},
),
const SizedBox(height: 30),
SizedBox(
width: size.width * 0.85,
child: LoadingButton(
controller: _btnController,
onPressed: _isSubmitting ? null : _submit,
iconData: Icons.login,
iconColor: theme.colorScheme.onPrimary,
child: Text(
'Connexion',
style: theme.textTheme.bodyLarge!.copyWith(
color: theme.colorScheme.onPrimary,
),
),
),
),
const SizedBox(height: 20),
TextButton(
onPressed: () {
debugPrint("[LOG] Redirection vers la page d'inscription.");
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SignUpScreen(),
),
);
},
child: Text(
'Pas encore de compte ? Inscrivez-vous',
style: theme.textTheme.bodyMedium!.copyWith(color: Colors.white70),
),
),
TextButton(
onPressed: () {
debugPrint("[LOG] Mot de passe oublié cliqué.");
},
child: Text(
'Mot de passe oublié ?',
style: theme.textTheme.bodyMedium!.copyWith(color: Colors.white70),
),
),
if (_showErrorMessage)
const Padding(
padding: EdgeInsets.only(top: 20),
child: Text(
'Erreur lors de la connexion. Veuillez vérifier vos identifiants.',
style: TextStyle(color: Colors.red, fontSize: 16),
textAlign: TextAlign.center,
),
),
],
),
),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
bottom: isKeyboardVisible ? 0 : 20,
left: isKeyboardVisible ? 20 : 0,
right: isKeyboardVisible ? 20 : 0,
child: Row(
mainAxisAlignment: isKeyboardVisible
? MainAxisAlignment.spaceBetween
: MainAxisAlignment.center,
children: [
Image.asset(
'lib/assets/images/logolionsdev.png',
height: 30,
),
if (isKeyboardVisible)
Text(
'© 2024',
style: theme.textTheme.bodyMedium!.copyWith(color: Colors.white70),
textAlign: TextAlign.center,
),
],
),
),
_buildBackground(theme),
_buildContent(theme, isKeyboardVisible),
_buildThemeToggle(theme),
if (_isSubmitting) _buildLoadingOverlay(theme),
],
),
);
}
/// Méthode pour construire les champs de formulaire avec les styles adaptés.
Widget _buildTextFormField({
required String label,
required IconData icon,
bool obscureText = false,
Widget? suffixIcon,
required FormFieldValidator<String> validator,
required FormFieldSetter<String> onSaved,
}) {
final theme = Theme.of(context);
/// Construit l'arrière-plan avec dégradé.
Widget _buildBackground(ThemeData theme) {
return AnimatedContainer(
duration: const Duration(seconds: 3),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.secondary,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
);
}
/// Construit le contenu principal.
Widget _buildContent(ThemeData theme, bool isKeyboardVisible) {
return FadeTransition(
opacity: _fadeAnimation,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isKeyboardVisible) _buildLogo(theme),
if (!isKeyboardVisible) const SizedBox(height: 32),
_buildTitle(theme),
const SizedBox(height: 40),
_buildEmailField(theme),
const SizedBox(height: 16),
_buildPasswordField(theme),
const SizedBox(height: 24),
_buildSubmitButton(theme),
const SizedBox(height: 16),
_buildSignUpLink(theme),
_buildForgotPasswordLink(theme),
if (_errorMessage != null) _buildErrorMessage(theme),
],
),
),
),
),
);
}
/// Construit le logo.
Widget _buildLogo(ThemeData theme) {
return Image.asset(
'lib/assets/images/logo.png',
height: 120,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.event,
size: 80,
color: theme.colorScheme.onPrimary,
);
},
);
}
/// Construit le titre.
Widget _buildTitle(ThemeData theme) {
return Text(
'Bienvenue sur AfterWork',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
);
}
/// Construit le champ email.
Widget _buildEmailField(ThemeData theme) {
return TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: label,
labelText: 'Email',
prefixIcon: Icon(Icons.email, color: theme.colorScheme.onPrimary),
filled: true,
fillColor: theme.inputDecorationTheme.fillColor,
labelStyle: theme.textTheme.bodyMedium,
fillColor: theme.colorScheme.onPrimary.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(icon, color: theme.iconTheme.color),
suffixIcon: suffixIcon,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.onPrimary.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.onPrimary,
width: 2,
),
),
labelStyle: TextStyle(color: theme.colorScheme.onPrimary),
),
style: TextStyle(color: theme.colorScheme.onPrimary),
validator: Validators.validateEmail,
);
}
/// Construit le champ mot de passe.
Widget _buildPasswordField(ThemeData theme) {
return TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleSubmit(),
decoration: InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock, color: theme.colorScheme.onPrimary),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
color: theme.colorScheme.onPrimary.withOpacity(0.7),
),
onPressed: _togglePasswordVisibility,
),
filled: true,
fillColor: theme.colorScheme.onPrimary.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.onPrimary.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.onPrimary,
width: 2,
),
),
labelStyle: TextStyle(color: theme.colorScheme.onPrimary),
),
style: TextStyle(color: theme.colorScheme.onPrimary),
validator: Validators.validatePassword,
);
}
/// Construit le bouton de soumission (compact).
Widget _buildSubmitButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: CustomButton(
text: 'Connexion',
icon: Icons.login,
onPressed: () => _handleSubmit(),
isLoading: _isSubmitting,
isEnabled: !_isSubmitting,
variant: ButtonVariant.primary,
size: ButtonSize.medium,
),
);
}
/// Construit le lien vers l'inscription.
Widget _buildSignUpLink(ThemeData theme) {
return TextButton(
onPressed: _navigateToSignUp,
child: Text(
'Pas encore de compte ? Inscrivez-vous',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onPrimary.withOpacity(0.9),
decoration: TextDecoration.underline,
),
),
);
}
/// Construit le lien mot de passe oublié.
Widget _buildForgotPasswordLink(ThemeData theme) {
return TextButton(
onPressed: _handleForgotPassword,
child: Text(
'Mot de passe oublié ?',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimary.withOpacity(0.7),
),
),
);
}
/// Construit le message d'erreur.
Widget _buildErrorMessage(ThemeData theme) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
),
);
}
/// Construit le bouton de basculement de thème.
Widget _buildThemeToggle(ThemeData theme) {
return Positioned(
top: MediaQuery.of(context).padding.top + 8,
right: 16,
child: IconButton(
icon: Icon(
Provider.of<ThemeProvider>(context).isDarkMode
? Icons.light_mode
: Icons.dark_mode,
color: theme.colorScheme.onPrimary,
),
onPressed: () {
Provider.of<ThemeProvider>(context, listen: false).toggleTheme();
},
tooltip: 'Changer le thème',
),
);
}
/// Construit l'overlay de chargement.
Widget _buildLoadingOverlay(ThemeData theme) {
return Container(
color: Colors.black.withOpacity(0.3),
child: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.onPrimary,
),
),
),
obscureText: obscureText,
style: theme.textTheme.bodyLarge,
validator: validator,
onSaved: onSaved,
);
}
}

View File

@@ -1,18 +1,307 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
class NotificationsScreen extends StatelessWidget {
const NotificationsScreen({Key? key}) : super(key: key);
import '../../../core/constants/env_config.dart';
import '../../../core/theme/theme_provider.dart';
import '../../../data/services/notification_service.dart';
import '../../../data/services/secure_storage.dart';
import '../../../domain/entities/notification.dart' as domain;
import '../../widgets/custom_button.dart';
import '../../widgets/custom_snackbar.dart';
import '../../widgets/modern_empty_state.dart';
import '../../widgets/shimmer_loading.dart';
import '../event/event_screen.dart';
import '../friends/friends_screen.dart';
/// Écran des notifications avec design moderne et fonctionnalités complètes
class NotificationsScreen extends StatefulWidget {
const NotificationsScreen({super.key});
@override
State<NotificationsScreen> createState() => _NotificationsScreenState();
}
class _NotificationsScreenState extends State<NotificationsScreen> {
@override
void initState() {
super.initState();
// Recharge les notifications au montage de l'écran
WidgetsBinding.instance.addPostFrameCallback((_) {
final notificationService = Provider.of<NotificationService>(context, listen: false);
notificationService.loadNotifications();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
backgroundColor: Colors.blueAccent,
),
body: const Center(
child: Text('Liste des notifications'),
final theme = Theme.of(context);
return Consumer<NotificationService>(
builder: (context, notificationService, child) {
final notifications = notificationService.notifications;
final hasUnread = notifications.any((n) => !n.isRead);
return Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
actions: [
if (hasUnread)
TextButton.icon(
onPressed: () => _markAllAsRead(notificationService),
icon: const Icon(Icons.done_all),
label: const Text('Tout marquer comme lu'),
),
],
),
body: notificationService.isLoading
? const SkeletonList(
itemCount: 5,
skeletonWidget: ListItemSkeleton(),
)
: notifications.isEmpty
? _buildEmptyState(context, theme, notificationService)
: RefreshIndicator(
onRefresh: () => notificationService.loadNotifications(),
color: theme.colorScheme.primary,
child: ListView.separated(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: notifications.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final notification = notifications[index];
return _buildNotificationTile(
context,
theme,
notification,
notificationService,
);
},
),
),
);
},
);
}
Widget _buildEmptyState(
BuildContext context,
ThemeData theme,
NotificationService notificationService,
) {
return RefreshIndicator(
onRefresh: () => notificationService.loadNotifications(),
color: theme.colorScheme.primary,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height - 200,
child: const ModernEmptyState(
illustration: EmptyStateIllustration.notifications,
title: 'Aucune notification',
description: 'Vous serez notifié des nouvelles activités, événements et interactions avec vos amis',
),
),
),
);
}
Widget _buildNotificationTile(
BuildContext context,
ThemeData theme,
domain.Notification notification,
NotificationService notificationService,
) {
return Dismissible(
key: Key(notification.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) async {
try {
await notificationService.deleteNotification(notification.id);
if (mounted) {
context.showSuccess('Notification supprimée');
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la suppression');
}
}
},
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getNotificationColor(theme, notification.type),
child: Icon(
_getNotificationIcon(notification.type),
color: Colors.white,
),
),
title: Text(
notification.title,
style: TextStyle(
fontWeight: notification.isRead ? FontWeight.normal : FontWeight.bold,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(notification.message),
const SizedBox(height: 4),
Text(
_formatTimestamp(notification.timestamp),
style: theme.textTheme.bodySmall,
),
],
),
trailing: notification.isRead
? null
: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
onTap: () {
_handleNotificationTap(notification, notificationService);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
Color _getNotificationColor(ThemeData theme, domain.NotificationType type) {
switch (type) {
case domain.NotificationType.event:
return theme.colorScheme.primary;
case domain.NotificationType.friend:
return theme.colorScheme.secondary;
case domain.NotificationType.reminder:
return theme.colorScheme.tertiary;
case domain.NotificationType.other:
return theme.colorScheme.primary;
}
}
IconData _getNotificationIcon(domain.NotificationType type) {
switch (type) {
case domain.NotificationType.event:
return Icons.event;
case domain.NotificationType.friend:
return Icons.person_add;
case domain.NotificationType.reminder:
return Icons.alarm;
case domain.NotificationType.other:
return Icons.notifications;
}
}
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inDays > 7) {
return 'Il y a ${difference.inDays ~/ 7} semaines';
} else if (difference.inDays > 0) {
return 'Il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
} else if (difference.inHours > 0) {
return 'Il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
} else if (difference.inMinutes > 0) {
return 'Il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
} else {
return 'À l\'instant';
}
}
Future<void> _handleNotificationTap(
domain.Notification notification,
NotificationService notificationService,
) async {
// Marquer comme lue
try {
await notificationService.markAsRead(notification.id);
} catch (e) {
if (EnvConfig.enableDetailedLogs) {
debugPrint('[NotificationsScreen] Erreur marquage lu: $e');
}
}
// Naviguer vers la page appropriée selon le type de notification
if (!mounted) return;
final secureStorage = SecureStorage();
final userId = await secureStorage.getUserId() ?? '';
switch (notification.type) {
case domain.NotificationType.event:
// Naviguer vers l'écran des événements
// Si eventId est disponible, on pourrait naviguer vers les détails
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EventScreen(
userId: userId,
userFirstName: '',
userLastName: '',
profileImageUrl: '',
),
),
);
break;
case domain.NotificationType.friend:
// Naviguer vers l'écran des amis
if (userId.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FriendsScreen(userId: userId),
),
);
}
break;
case domain.NotificationType.reminder:
// Naviguer vers l'écran des événements
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EventScreen(
userId: userId,
userFirstName: '',
userLastName: '',
profileImageUrl: '',
),
),
);
break;
case domain.NotificationType.other:
// Pas de navigation spécifique
break;
}
}
Future<void> _markAllAsRead(NotificationService notificationService) async {
try {
await notificationService.markAllAsRead();
if (mounted) {
context.showSuccess('Toutes les notifications ont été marquées comme lues');
}
} catch (e) {
if (mounted) {
context.showError('Erreur: ${e.toString()}');
}
}
}
}

View File

@@ -0,0 +1,544 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/design_system.dart';
import '../../../data/datasources/user_remote_data_source.dart';
import '../../../data/models/user_model.dart';
import '../../../data/providers/user_provider.dart';
import '../../../data/services/preferences_helper.dart';
import '../../../data/services/secure_storage.dart';
import '../../../domain/entities/user.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/custom_snackbar.dart';
/// Écran d'édition de profil utilisateur avec design moderne.
///
/// Permet à l'utilisateur de modifier:
/// - Prénom et nom
/// - Email
/// - Photo de profil
/// - Mot de passe
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({required this.user, super.key});
final User user;
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final UserRemoteDataSource _dataSource = UserRemoteDataSource(http.Client());
final SecureStorage _secureStorage = SecureStorage();
final PreferencesHelper _preferencesHelper = PreferencesHelper();
final ImagePicker _imagePicker = ImagePicker();
bool _isLoading = false;
bool _hasChanges = false;
File? _newProfileImage;
String? _newProfileImageUrl;
@override
void initState() {
super.initState();
_firstNameController.text = widget.user.userFirstName;
_lastNameController.text = widget.user.userLastName;
_emailController.text = widget.user.email;
// Écoute les changements pour activer/désactiver le bouton Sauvegarder
_firstNameController.addListener(_onFieldChanged);
_lastNameController.addListener(_onFieldChanged);
_emailController.addListener(_onFieldChanged);
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
super.dispose();
}
void _onFieldChanged() {
final hasChanges = _firstNameController.text != widget.user.userFirstName ||
_lastNameController.text != widget.user.userLastName ||
_emailController.text != widget.user.email ||
_newProfileImage != null;
if (hasChanges != _hasChanges) {
setState(() {
_hasChanges = hasChanges;
});
}
}
Future<void> _pickImage() async {
try {
final XFile? pickedFile = await _imagePicker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
maxHeight: 1024,
imageQuality: 85,
);
if (pickedFile != null) {
setState(() {
_newProfileImage = File(pickedFile.path);
_hasChanges = true;
});
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la sélection de l\'image');
}
}
}
Future<void> _saveChanges() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (!_hasChanges) {
context.showInfo('Aucune modification à enregistrer');
return;
}
setState(() {
_isLoading = true;
});
try {
// TODO: Si une nouvelle image est sélectionnée, l'uploader d'abord
// et récupérer l'URL depuis le backend
String profileImageUrl = widget.user.profileImageUrl;
if (_newProfileImage != null) {
// Pour l'instant, on utilise l'URL existante
// Dans une vraie implémentation, il faudrait uploader l'image
// vers le backend et récupérer la nouvelle URL
context.showWarning('L\'upload d\'image sera implémenté avec le backend');
}
// Créer le modèle utilisateur mis à jour
final updatedUser = UserModel(
userId: widget.user.userId,
userFirstName: _firstNameController.text.trim(),
userLastName: _lastNameController.text.trim(),
email: _emailController.text.trim(),
motDePasse: widget.user.motDePasse, // Mot de passe inchangé
profileImageUrl: profileImageUrl,
);
// Envoyer la mise à jour au backend
final result = await _dataSource.updateUser(updatedUser);
if (mounted) {
// Mettre à jour le provider
final userProvider = Provider.of<UserProvider>(context, listen: false);
userProvider.setUser(result.toEntity());
// Mettre à jour le stockage local
await _preferencesHelper.saveUserName(result.userFirstName);
await _preferencesHelper.saveUserLastName(result.userLastName);
setState(() {
_isLoading = false;
_hasChanges = false;
});
context.showSuccess('Profil mis à jour avec succès');
Navigator.pop(context, true); // Retourne true pour indiquer la modification
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
context.showError('Erreur lors de la mise à jour: ${e.toString()}');
}
}
}
Future<void> _changePassword() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => const _ChangePasswordDialog(),
);
if (result == true && mounted) {
context.showSuccess('Mot de passe modifié avec succès');
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Modifier le profil'),
actions: [
if (_hasChanges && !_isLoading)
TextButton(
onPressed: _saveChanges,
child: const Text(
'SAUVEGARDER',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Photo de profil
Center(
child: Stack(
children: [
Hero(
tag: 'user_profile_avatar_${widget.user.userId}',
child: CircleAvatar(
radius: 60,
backgroundImage: _newProfileImage != null
? FileImage(_newProfileImage!)
: NetworkImage(widget.user.profileImageUrl) as ImageProvider,
child: _newProfileImage == null &&
widget.user.profileImageUrl.isEmpty
? const Icon(Icons.person, size: 60)
: null,
),
),
Positioned(
right: 0,
bottom: 0,
child: AnimatedScaleButton(
onTap: _pickImage,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
boxShadow: DesignSystem.shadowMd,
),
child: const Icon(
Icons.camera_alt,
color: Colors.white,
size: 20,
),
),
),
),
],
),
),
const SizedBox(height: DesignSystem.spacing2xl),
// Prénom
TextFormField(
controller: _firstNameController,
decoration: InputDecoration(
labelText: 'Prénom',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le prénom est requis';
}
if (value.trim().length < 2) {
return 'Le prénom doit contenir au moins 2 caractères';
}
return null;
},
),
const SizedBox(height: DesignSystem.spacingLg),
// Nom
TextFormField(
controller: _lastNameController,
decoration: InputDecoration(
labelText: 'Nom',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le nom est requis';
}
if (value.trim().length < 2) {
return 'Le nom doit contenir au moins 2 caractères';
}
return null;
},
),
const SizedBox(height: DesignSystem.spacingLg),
// Email
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'L\'email est requis';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Email invalide';
}
return null;
},
),
const SizedBox(height: DesignSystem.spacing2xl),
// Bouton Changer le mot de passe
OutlinedButton.icon(
onPressed: _changePassword,
icon: const Icon(Icons.lock),
label: const Text('Changer le mot de passe'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
),
),
const SizedBox(height: DesignSystem.spacingLg),
// Bouton Sauvegarder (grand bouton en bas)
if (_hasChanges)
FadeInWidget(
child: ElevatedButton(
onPressed: _isLoading ? null : _saveChanges,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Enregistrer les modifications',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
);
}
}
/// Dialogue pour changer le mot de passe
class _ChangePasswordDialog extends StatefulWidget {
const _ChangePasswordDialog();
@override
State<_ChangePasswordDialog> createState() => _ChangePasswordDialogState();
}
class _ChangePasswordDialogState extends State<_ChangePasswordDialog> {
final _formKey = GlobalKey<FormState>();
final _currentPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
bool _obscureCurrentPassword = true;
bool _obscureNewPassword = true;
bool _obscureConfirmPassword = true;
@override
void dispose() {
_currentPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _changePassword() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
// TODO: Appel API pour changer le mot de passe
// await _dataSource.changePassword(
// currentPassword: _currentPasswordController.text,
// newPassword: _newPasswordController.text,
// );
// Simulation pour l'instant
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
context.showError('Erreur: ${e.toString()}');
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Changer le mot de passe'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Mot de passe actuel
TextFormField(
controller: _currentPasswordController,
obscureText: _obscureCurrentPassword,
decoration: InputDecoration(
labelText: 'Mot de passe actuel',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureCurrentPassword ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscureCurrentPassword = !_obscureCurrentPassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Mot de passe requis';
}
return null;
},
),
const SizedBox(height: DesignSystem.spacingLg),
// Nouveau mot de passe
TextFormField(
controller: _newPasswordController,
obscureText: _obscureNewPassword,
decoration: InputDecoration(
labelText: 'Nouveau mot de passe',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscureNewPassword ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscureNewPassword = !_obscureNewPassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nouveau mot de passe requis';
}
if (value.length < 8) {
return 'Minimum 8 caractères';
}
return null;
},
),
const SizedBox(height: DesignSystem.spacingLg),
// Confirmer nouveau mot de passe
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: 'Confirmer le mot de passe',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Confirmation requise';
}
if (value != _newPasswordController.text) {
return 'Les mots de passe ne correspondent pas';
}
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: _isLoading ? null : _changePassword,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Changer'),
),
],
);
}
}

View File

@@ -1,91 +1,275 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/colors.dart';
import '../../../data/providers/user_provider.dart';
import '../../widgets/cards/account_deletion_card.dart';
import '../../widgets/cards/statistics_section_card.dart';
import '../../widgets/cards/support_section_card.dart';
import '../../widgets/custom_list_tile.dart';
import '../../widgets/cards/edit_options_card.dart';
import '../../widgets/cards/expandable_section_card.dart';
import '../../widgets/profile_header.dart';
import '../../widgets/cards/user_info_card.dart';
import '../../../core/constants/design_system.dart';
import '../../../core/theme/theme_provider.dart';
import '../../../core/utils/page_transitions.dart';
import '../../../data/providers/user_provider.dart';
import '../../../data/services/secure_storage.dart';
import '../../widgets/animated_widgets.dart';
import '../../widgets/custom_snackbar.dart';
import '../notifications/notifications_screen.dart';
import '../settings/settings_screen.dart';
import 'edit_profile_screen.dart';
/// Écran de profil moderne et épuré.
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final userProvider = Provider.of<UserProvider>(context);
final user = userProvider.user;
final themeProvider = Provider.of<ThemeProvider>(context);
return Scaffold(
backgroundColor: AppColors.backgroundColor,
body: CustomScrollView(
slivers: [
ProfileHeader(user: user),
SliverList(
delegate: SliverChildListDelegate(
[
const SizedBox(height: 10),
UserInfoCard(user: user),
const SizedBox(height: 10),
const EditOptionsCard(),
const SizedBox(height: 10),
StatisticsSectionCard(user: user),
const SizedBox(height: 10),
ExpandableSectionCard(
title: 'Historique',
icon: Icons.history,
children: [
CustomListTile(
icon: Icons.event_note,
label: 'Historique des Événements',
onTap: () => print("[LOG] Accès à l'historique des événements."),
// AppBar avec image de fond et avatar
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
// Fond dégradé
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withOpacity(0.7),
],
),
),
CustomListTile(
icon: Icons.history,
label: 'Historique des Publications',
onTap: () => print("[LOG] Accès à l'historique des publications."),
),
// Avatar centré
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Hero(
tag: 'user_profile_avatar_${user.userId}',
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: theme.scaffoldBackgroundColor,
width: 4,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: CircleAvatar(
radius: 50,
backgroundImage: user.profileImageUrl != null &&
user.profileImageUrl!.isNotEmpty
? NetworkImage(user.profileImageUrl!)
: null,
child: user.profileImageUrl == null ||
user.profileImageUrl!.isEmpty
? Icon(
Icons.person_rounded,
size: 50,
color: theme.colorScheme.onPrimary,
)
: null,
),
),
),
),
CustomListTile(
icon: Icons.bookmark,
label: 'Historique de Réservations',
onTap: () => print("[LOG] Accès à l'historique des réservations."),
),
],
),
],
),
),
),
// Contenu
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(height: DesignSystem.spacingLg),
// Nom et email
Text(
'${user.userFirstName} ${user.userLastName}',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
ExpandableSectionCard(
title: 'Préférences et Paramètres',
icon: Icons.settings,
children: [
CustomListTile(
icon: Icons.privacy_tip,
label: 'Paramètres de confidentialité',
onTap: () => print("[LOG] Accès aux paramètres de confidentialité."),
),
CustomListTile(
icon: Icons.notifications,
label: 'Notifications',
onTap: () => print("[LOG] Accès aux paramètres de notifications."),
),
CustomListTile(
icon: Icons.language,
label: 'Langue de l\'application',
onTap: () => print("[LOG] Accès aux paramètres de langue."),
),
CustomListTile(
icon: Icons.format_paint,
label: 'Thème de l\'application',
onTap: () => print("[LOG] Accès aux paramètres de thème."),
),
],
const SizedBox(height: DesignSystem.spacingSm),
Text(
user.email,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: DesignSystem.spacingXl),
// Bouton Éditer le profil
Padding(
padding: DesignSystem.paddingHorizontal(DesignSystem.spacingXl),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
context.pushSlideRight(EditProfileScreen(user: user));
},
icon: const Icon(Icons.edit_rounded, size: 18),
label: const Text('Éditer le profil'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
),
),
),
),
const SizedBox(height: DesignSystem.spacingXl),
// Statistiques
_buildStatsRow(theme, user),
const SizedBox(height: DesignSystem.spacingXl),
// Sections
Padding(
padding: DesignSystem.paddingHorizontal(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Historique
_buildSectionHeader(theme, 'Historique'),
const SizedBox(height: DesignSystem.spacingSm),
_buildListTile(
context,
theme,
Icons.event_note_rounded,
'Historique des Événements',
() => context.showInfo('Historique des événements à venir'),
),
_buildListTile(
context,
theme,
Icons.article_rounded,
'Historique des Publications',
() => context.showInfo('Historique des publications à venir'),
),
_buildListTile(
context,
theme,
Icons.bookmark_rounded,
'Historique de Réservations',
() => context.showInfo('Historique des réservations à venir'),
),
const SizedBox(height: DesignSystem.spacingXl),
// Section Paramètres
_buildSectionHeader(theme, 'Paramètres'),
const SizedBox(height: DesignSystem.spacingSm),
_buildListTile(
context,
theme,
Icons.privacy_tip_rounded,
'Confidentialité',
() => context.showInfo('Paramètres de confidentialité à venir'),
),
_buildListTile(
context,
theme,
Icons.notifications_rounded,
'Notifications',
() => context.pushFadeScale(const NotificationsScreen()),
),
_buildListTile(
context,
theme,
Icons.language_rounded,
'Langue',
() => context.showInfo('Sélection de langue à venir'),
),
_buildSwitchTile(
context,
theme,
themeProvider.isDarkMode ? Icons.light_mode_rounded : Icons.dark_mode_rounded,
'Mode sombre',
themeProvider.isDarkMode,
(value) => themeProvider.toggleTheme(),
),
_buildListTile(
context,
theme,
Icons.settings_rounded,
'Paramètres avancés',
() => context.pushSlideRight(const SettingsScreen()),
),
const SizedBox(height: DesignSystem.spacingXl),
// Section Aide
_buildSectionHeader(theme, 'Aide & Support'),
const SizedBox(height: DesignSystem.spacingSm),
_buildListTile(
context,
theme,
Icons.help_rounded,
'Centre d\'aide',
() => context.showInfo('Centre d\'aide à venir'),
),
_buildListTile(
context,
theme,
Icons.feedback_rounded,
'Envoyer un feedback',
() => context.showInfo('Formulaire de feedback à venir'),
),
_buildListTile(
context,
theme,
Icons.info_rounded,
'À propos',
() => context.showInfo('À propos de l\'application'),
),
const SizedBox(height: DesignSystem.spacingXl),
// Bouton Déconnexion
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
_showLogoutDialog(context);
},
icon: const Icon(Icons.logout_rounded, size: 18),
label: const Text('Se déconnecter'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(color: theme.colorScheme.error),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
),
),
),
const SizedBox(height: DesignSystem.spacingXl),
],
),
),
const SizedBox(height: 10),
const SupportSectionCard(),
const SizedBox(height: 10),
AccountDeletionCard(context: context),
],
),
),
@@ -93,4 +277,183 @@ class ProfileScreen extends StatelessWidget {
),
);
}
Widget _buildStatsRow(ThemeData theme, dynamic user) {
return Padding(
padding: DesignSystem.paddingHorizontal(DesignSystem.spacingXl),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(theme, '0', 'Publications'),
Container(
height: 40,
width: 1,
color: theme.dividerColor,
),
_buildStatItem(theme, '0', 'Amis'),
Container(
height: 40,
width: 1,
color: theme.dividerColor,
),
_buildStatItem(theme, '0', 'Événements'),
],
),
);
}
Widget _buildStatItem(ThemeData theme, String value, String label) {
return Column(
children: [
Text(
value,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
),
],
);
}
Widget _buildSectionHeader(ThemeData theme, String title) {
return Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 15,
),
);
}
Widget _buildListTile(
BuildContext context,
ThemeData theme,
IconData icon,
String title,
VoidCallback onTap,
) {
return AnimatedCard(
margin: const EdgeInsets.only(bottom: DesignSystem.spacingSm),
borderRadius: DesignSystem.borderRadiusMd,
elevation: 0.5,
hoverElevation: 2,
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingMd,
),
onTap: onTap,
child: Row(
children: [
Icon(
icon,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
),
),
),
Icon(
Icons.chevron_right_rounded,
size: 20,
color: theme.colorScheme.onSurface.withOpacity(0.3),
),
],
),
);
}
Widget _buildSwitchTile(
BuildContext context,
ThemeData theme,
IconData icon,
String title,
bool value,
ValueChanged<bool> onChanged,
) {
return AnimatedCard(
margin: const EdgeInsets.only(bottom: DesignSystem.spacingSm),
borderRadius: DesignSystem.borderRadiusMd,
elevation: 0.5,
hoverElevation: 1,
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
child: Row(
children: [
Icon(
icon,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
),
),
),
Switch(
value: value,
onChanged: onChanged,
),
],
),
);
}
void _showLogoutDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () async {
// Fermer le dialogue
Navigator.pop(context);
// Supprimer les informations de l'utilisateur du stockage sécurisé
final secureStorage = SecureStorage();
await secureStorage.deleteUserInfo();
// Naviguer vers l'écran de connexion et supprimer toute la pile de navigation
if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
context.showSuccess('Déconnexion effectuée');
}
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Déconnecter'),
),
],
),
);
}
}

View File

@@ -1,56 +1,383 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SettingsScreen extends StatelessWidget {
import '../../../core/theme/theme_provider.dart';
import '../../../data/providers/user_provider.dart';
import '../../../data/services/secure_storage.dart';
import '../../widgets/custom_button.dart';
import '../../widgets/custom_list_tile.dart';
import '../login/login_screen.dart';
import '../profile/profile_screen.dart';
/// Écran des paramètres avec toutes les options et design moderne.
///
/// Cet écran permet de gérer les préférences utilisateur, les notifications,
/// la sécurité, et d'autres paramètres avec une interface organisée et compacte.
///
/// **Fonctionnalités:**
/// - Gestion du profil et sécurité
/// - Préférences (thème, langue)
/// - Notifications
/// - Aide et support
/// - Déconnexion
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
// ============================================================================
// ÉTATS
// ============================================================================
bool _notificationsEnabled = true;
bool _locationEnabled = false;
String _selectedLanguage = 'Français';
final SecureStorage _secureStorage = SecureStorage();
// ============================================================================
// BUILD
// ============================================================================
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Paramètres'),
backgroundColor: Colors.black,
elevation: 0,
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
ListTile(
leading: Icon(Icons.account_circle, color: Colors.blueAccent),
title: const Text('Compte', style: TextStyle(color: Colors.white)),
onTap: () {
// Logique pour gérer l'option Compte
_buildAccountSection(theme),
_buildPreferencesSection(theme),
_buildNotificationsSection(theme),
_buildHelpSection(theme),
_buildLogoutSection(theme),
const SizedBox(height: 16),
],
),
);
}
// ============================================================================
// SECTIONS
// ============================================================================
/// Construit la section compte.
Widget _buildAccountSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(theme, 'Compte'),
CustomListTile(
icon: Icons.person,
label: 'Profil',
subtitle: 'Gérer vos informations personnelles',
onTap: _handleProfile,
),
CustomListTile(
icon: Icons.security,
label: 'Sécurité',
subtitle: 'Mot de passe et authentification',
onTap: _handleSecurity,
),
CustomListTile(
icon: Icons.privacy_tip,
label: 'Confidentialité',
subtitle: 'Contrôler votre vie privée',
onTap: _handlePrivacy,
),
const Divider(height: 32),
],
);
}
/// Construit la section préférences.
Widget _buildPreferencesSection(ThemeData theme) {
final themeProvider = Provider.of<ThemeProvider>(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(theme, 'Préférences'),
SwitchListTile(
secondary: Icon(
Icons.dark_mode,
color: theme.colorScheme.primary,
),
title: const Text('Mode sombre'),
subtitle: const Text('Activer le thème sombre'),
value: themeProvider.isDarkMode,
onChanged: (value) {
themeProvider.toggleTheme();
},
),
ListTile(
leading: Icon(Icons.language, color: theme.colorScheme.primary),
title: const Text('Langue'),
subtitle: Text(_selectedLanguage),
trailing: DropdownButton<String>(
value: _selectedLanguage,
underline: const SizedBox(),
items: const [
DropdownMenuItem(value: 'Français', child: Text('Français')),
DropdownMenuItem(value: 'English', child: Text('English')),
],
onChanged: (value) {
if (value != null) {
setState(() {
_selectedLanguage = value;
});
}
},
),
ListTile(
leading: Icon(Icons.notifications, color: Colors.blueAccent),
title: const Text('Notifications', style: TextStyle(color: Colors.white)),
onTap: () {
// Logique pour gérer les notifications
},
),
const Divider(height: 32),
],
);
}
/// Construit la section notifications.
Widget _buildNotificationsSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(theme, 'Notifications'),
SwitchListTile(
secondary: Icon(
Icons.notifications,
color: theme.colorScheme.primary,
),
ListTile(
leading: Icon(Icons.lock, color: Colors.blueAccent),
title: const Text('Sécurité', style: TextStyle(color: Colors.white)),
onTap: () {
// Logique pour gérer la sécurité
},
title: const Text('Notifications push'),
subtitle: const Text('Recevoir des notifications'),
value: _notificationsEnabled,
onChanged: (value) {
setState(() {
_notificationsEnabled = value;
});
},
),
SwitchListTile(
secondary: Icon(
Icons.location_on,
color: theme.colorScheme.primary,
),
ListTile(
leading: Icon(Icons.help, color: Colors.blueAccent),
title: const Text('Aide', style: TextStyle(color: Colors.white)),
onTap: () {
// Logique pour gérer l'aide
},
title: const Text('Localisation'),
subtitle: const Text('Partager votre localisation'),
value: _locationEnabled,
onChanged: (value) {
setState(() {
_locationEnabled = value;
});
},
),
const Divider(height: 32),
],
);
}
/// Construit la section aide.
Widget _buildHelpSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(theme, 'Aide et Support'),
CustomListTile(
icon: Icons.help_outline,
label: 'Centre d\'aide',
subtitle: 'FAQ et support',
onTap: _handleHelp,
),
CustomListTile(
icon: Icons.feedback,
label: 'Envoyer un commentaire',
subtitle: 'Partagez vos suggestions',
onTap: _handleFeedback,
),
CustomListTile(
icon: Icons.info_outline,
label: 'À propos',
subtitle: 'Version 1.0.0',
onTap: _handleAbout,
),
const Divider(height: 32),
],
);
}
/// Construit la section déconnexion.
Widget _buildLogoutSection(ThemeData theme) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: CustomButton(
text: 'Déconnexion',
icon: Icons.logout,
onPressed: _handleLogout,
variant: ButtonVariant.outlined,
size: ButtonSize.medium,
),
);
}
// ============================================================================
// WIDGETS UTILITAIRES
// ============================================================================
/// Construit l'en-tête de section.
Widget _buildSectionHeader(ThemeData theme, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
// ============================================================================
// ACTIONS
// ============================================================================
/// Gère la navigation vers le profil.
void _handleProfile() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ProfileScreen(),
),
);
}
/// Gère la sécurité.
void _handleSecurity() {
_showDialog(
title: 'Sécurité',
content: 'Fonctionnalité de sécurité à venir',
);
}
/// Gère la confidentialité.
void _handlePrivacy() {
_showDialog(
title: 'Confidentialité',
content: 'Paramètres de confidentialité à venir',
);
}
/// Gère l'aide.
void _handleHelp() {
_showDialog(
title: 'Centre d\'aide',
content: 'FAQ et support à venir',
);
}
/// Gère le feedback.
void _handleFeedback() {
_showDialog(
title: 'Envoyer un commentaire',
content: 'Formulaire de feedback à venir',
);
}
/// Gère l'à propos.
void _handleAbout() {
showAboutDialog(
context: context,
applicationName: 'AfterWork',
applicationVersion: '1.0.0',
applicationIcon: const Icon(Icons.event, size: 48),
children: const [
Text('Application de gestion d\'événements sociaux'),
],
);
}
/// Gère la déconnexion.
void _handleLogout() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ListTile(
leading: Icon(Icons.info, color: Colors.blueAccent),
title: const Text('À propos', style: TextStyle(color: Colors.white)),
onTap: () {
// Logique pour gérer les informations À propos
TextButton(
onPressed: () async {
Navigator.pop(context);
await _performLogout();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Déconnexion'),
),
],
),
backgroundColor: Colors.black,
);
}
/// Affiche une boîte de dialogue simple.
void _showDialog({required String title, required String content}) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
/// Effectue la déconnexion complète de l'utilisateur.
Future<void> _performLogout() async {
try {
// Réinitialiser le UserProvider
Provider.of<UserProvider>(context, listen: false).resetUser();
// Supprimer toutes les données utilisateur du stockage sécurisé
await _secureStorage.deleteUserInfo();
if (mounted) {
// Naviguer vers l'écran de connexion
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) => const LoginScreen(),
),
(route) => false,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Déconnexion réussie'),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la déconnexion: ${e.toString()}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
}

View File

@@ -1,394 +1,545 @@
import 'dart:async';
import 'package:afterwork/data/datasources/user_remote_data_source.dart';
import 'package:afterwork/data/models/user_model.dart';
import 'package:afterwork/data/services/preferences_helper.dart';
import 'package:afterwork/data/services/secure_storage.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:loading_icon_button/loading_icon_button.dart';
import 'package:provider/provider.dart';
import '../../../core/theme/theme_provider.dart';
/// Écran d'inscription pour l'application AfterWork.
/// Permet à l'utilisateur de créer un nouveau compte avec des champs comme nom, prénom, email, mot de passe.
import '../../../core/constants/env_config.dart';
import '../../../core/theme/theme_provider.dart';
import '../../../core/utils/validators.dart';
import '../../../data/datasources/user_remote_data_source.dart';
import '../../../data/models/user_model.dart';
import '../../../data/services/preferences_helper.dart';
import '../../../data/services/secure_storage.dart';
import '../../widgets/custom_button.dart';
import '../login/login_screen.dart';
/// Écran d'inscription avec design moderne et compact.
///
/// Cet écran permet aux utilisateurs de créer un nouveau compte avec
/// validation des champs, gestion d'erreurs, et interface optimisée.
///
/// **Fonctionnalités:**
/// - Validation des champs en temps réel
/// - Vérification de correspondance des mots de passe
/// - Affichage/masquage du mot de passe
/// - Gestion d'erreurs robuste
/// - Support du thème clair/sombre
class SignUpScreen extends StatefulWidget {
const SignUpScreen({super.key});
@override
_SignUpScreenState createState() => _SignUpScreenState();
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends State<SignUpScreen> {
final _formKey = GlobalKey<FormState>(); // Clé pour valider le formulaire
class _SignUpScreenState extends State<SignUpScreen>
with SingleTickerProviderStateMixin {
// ============================================================================
// FORMULAIRE
// ============================================================================
// Champs utilisateur
String _nom = ''; // Nom de l'utilisateur
String _prenoms = ''; // Prénom de l'utilisateur
String _email = ''; // Email de l'utilisateur
String _password = ''; // Mot de passe de l'utilisateur
String _confirmPassword = ''; // Confirmation du mot de passe
final _formKey = GlobalKey<FormState>();
final _lastNameController = TextEditingController();
final _firstNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
// États de gestion
bool _isPasswordVisible = false; // Pour afficher/masquer le mot de passe
bool _isSubmitting = false; // Indicateur pour l'état de soumission du formulaire
bool _showErrorMessage = false; // Affichage des erreurs
// ============================================================================
// ÉTATS
// ============================================================================
// Services pour les opérations
final UserRemoteDataSource _userRemoteDataSource = UserRemoteDataSource(http.Client());
final SecureStorage _secureStorage = SecureStorage();
final PreferencesHelper _preferencesHelper = PreferencesHelper();
bool _isPasswordVisible = false;
bool _isSubmitting = false;
String? _errorMessage;
// Contrôleur pour le bouton de chargement
final _btnController = LoadingButtonController();
// ============================================================================
// SERVICES
// ============================================================================
late final UserRemoteDataSource _userRemoteDataSource;
late final SecureStorage _secureStorage;
late final PreferencesHelper _preferencesHelper;
// ============================================================================
// ANIMATIONS
// ============================================================================
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_initializeServices();
_initializeAnimations();
}
void _initializeServices() {
_userRemoteDataSource = UserRemoteDataSource(http.Client());
_secureStorage = SecureStorage();
_preferencesHelper = PreferencesHelper();
}
void _initializeAnimations() {
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeIn,
);
_animationController.forward();
}
@override
void dispose() {
_btnController.reset();
_lastNameController.dispose();
_firstNameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_animationController.dispose();
super.dispose();
}
/// Fonction pour basculer la visibilité du mot de passe
// ============================================================================
// ACTIONS
// ============================================================================
/// Bascule la visibilité du mot de passe.
void _togglePasswordVisibility() {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
print("Visibilité du mot de passe basculée: $_isPasswordVisible");
}
Future<void> _submit() async {
print("Tentative de soumission du formulaire d'inscription.");
/// Soumet le formulaire d'inscription.
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_formKey.currentState!.validate()) {
// Vérifier la correspondance des mots de passe
if (_passwordController.text != _confirmPasswordController.text) {
setState(() {
_isSubmitting = true;
_showErrorMessage = false;
_errorMessage = 'Les mots de passe ne correspondent pas';
});
_formKey.currentState!.save();
return;
}
// Vérifier si le mot de passe et la confirmation correspondent
if (_password != _confirmPassword) {
setState(() {
_showErrorMessage = true;
});
print("Les mots de passe ne correspondent pas.");
_btnController.reset();
return;
}
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
try {
_btnController.start();
try {
final user = UserModel(
userId: '',
userLastName: _lastNameController.text.trim(),
userFirstName: _firstNameController.text.trim(),
email: _emailController.text.trim(),
motDePasse: _passwordController.text,
profileImageUrl: '',
);
// Créer l'utilisateur avec les informations fournies
final UserModel user = UserModel(
userId: '', // L'ID sera généré côté serveur
userLastName: _nom,
userFirstName: _prenoms,
email: _email,
motDePasse: _password, // Le mot de passe sera envoyé en clair pour l'instant
profileImageUrl: '',
);
final createdUser = await _userRemoteDataSource.createUser(user);
// Envoi des informations pour créer un nouvel utilisateur
final createdUser = await _userRemoteDataSource.createUser(user);
print("Utilisateur créé : ${createdUser.userId}");
// Sauvegarder les informations de l'utilisateur
await _secureStorage.saveUserId(createdUser.userId);
await _preferencesHelper.saveUserName(createdUser.userLastName);
await _preferencesHelper.saveUserLastName(createdUser.userFirstName);
// Rediriger vers la page d'accueil ou une page de confirmation
Navigator.pushReplacementNamed(context, '/home');
} catch (e) {
print("Erreur lors de la création du compte : $e");
_btnController.error();
setState(() {
_showErrorMessage = true;
});
} finally {
_btnController.reset();
await _saveUserData(createdUser);
await _navigateToLogin();
} catch (e) {
_handleError(e);
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
} else {
print("Échec de validation du formulaire.");
_btnController.reset();
}
}
/// Sauvegarde les données utilisateur.
Future<void> _saveUserData(UserModel user) async {
await _secureStorage.saveUserId(user.userId);
await _preferencesHelper.saveUserName(user.userLastName);
await _preferencesHelper.saveUserLastName(user.userFirstName);
}
/// Navigue vers l'écran de connexion.
Future<void> _navigateToLogin() async {
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const LoginScreen(),
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte créé avec succès ! Connectez-vous maintenant'),
behavior: SnackBarBehavior.floating,
),
);
}
/// Gère les erreurs d'inscription.
void _handleError(Object error) {
String message = 'Erreur lors de la création du compte';
if (error.toString().contains('Conflict') ||
error.toString().contains('existe déjà')) {
message = 'Un compte avec cet email existe déjà';
} else if (error.toString().contains('network') ||
error.toString().contains('timeout')) {
message = 'Problème de connexion. Vérifiez votre réseau';
} else {
message = error.toString().replaceAll('Exception: ', '');
}
setState(() {
_errorMessage = message;
});
if (EnvConfig.enableDetailedLogs) {
debugPrint('[SignUpScreen] Erreur: $error');
}
}
// ============================================================================
// BUILD
// ============================================================================
@override
Widget build(BuildContext context) {
final theme = Theme.of(context); // Utilisation du thème global
final size = MediaQuery.of(context).size;
final themeProvider = Provider.of<ThemeProvider>(context);
// Vérification si le clavier est visible
bool isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom != 0;
final theme = Theme.of(context);
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
return Scaffold(
body: Stack(
children: [
// Arrière-plan animé avec un dégradé basé sur le thème
AnimatedContainer(
duration: const Duration(seconds: 3),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.secondary
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
if (_isSubmitting)
const Center(
child: CircularProgressIndicator(),
),
// Bouton pour basculer entre les modes jour et nuit
Positioned(
top: 40,
right: 20,
child: IconButton(
icon: Icon(
themeProvider.isDarkMode ? Icons.dark_mode : Icons.light_mode,
color: theme.iconTheme.color,
),
onPressed: () {
themeProvider.toggleTheme();
print("Thème basculé : ${themeProvider.isDarkMode ? 'Sombre' : 'Clair'}");
},
),
),
// Formulaire d'inscription
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo de l'application
Image.asset(
'lib/assets/images/logo.png',
height: size.height * 0.25,
),
const SizedBox(height: 20),
// Titre de la page
Text(
'Créer un compte AfterWork',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 40),
// Champ nom
_buildTextFormField(
label: 'Nom',
icon: Icons.person,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre nom';
}
return null;
},
onSaved: (value) {
_nom = value!;
},
),
const SizedBox(height: 20),
// Champ prénom
_buildTextFormField(
label: 'Prénoms',
icon: Icons.person_outline,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre prénom';
}
return null;
},
onSaved: (value) {
_prenoms = value!;
},
),
const SizedBox(height: 20),
// Champ email
_buildTextFormField(
label: 'Email',
icon: Icons.email,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre email';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
return 'Veuillez entrer un email valide';
}
return null;
},
onSaved: (value) {
_email = value!;
},
),
const SizedBox(height: 20),
// Champ mot de passe
_buildTextFormField(
label: 'Mot de passe',
icon: Icons.lock,
obscureText: !_isPasswordVisible,
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
color: theme.iconTheme.color,
),
onPressed: _togglePasswordVisibility,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre mot de passe';
}
if (value.length < 6) {
return 'Le mot de passe doit comporter au moins 6 caractères';
}
return null;
},
onSaved: (value) {
_password = value!;
},
),
const SizedBox(height: 20),
// Champ de confirmation du mot de passe
_buildTextFormField(
label: 'Confirmer le mot de passe',
icon: Icons.lock_outline,
obscureText: !_isPasswordVisible,
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
color: theme.iconTheme.color,
),
onPressed: _togglePasswordVisibility,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez confirmer votre mot de passe';
}
if (value != _password) {
return 'Les mots de passe ne correspondent pas';
}
return null;
},
onSaved: (value) {
_confirmPassword = value!;
},
),
const SizedBox(height: 30),
// Bouton de création de compte
SizedBox(
width: size.width * 0.85,
child: LoadingButton(
controller: _btnController,
onPressed: _isSubmitting ? null : _submit,
iconData: Icons.person_add,
iconColor: theme.colorScheme.onPrimary,
child: Text(
'Créer un compte',
style: theme.textTheme.bodyLarge!.copyWith(
color: theme.colorScheme.onPrimary,
),
),
),
),
const SizedBox(height: 20),
// Lien pour revenir à la connexion
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(
'Déjà un compte ? Connectez-vous',
style: theme.textTheme.bodyMedium!
.copyWith(color: Colors.white70),
),
),
// Affichage du message d'erreur si nécessaire
if (_showErrorMessage)
const Padding(
padding: EdgeInsets.only(top: 20),
child: Text(
'Erreur lors de la création du compte. Veuillez vérifier vos informations.',
style: TextStyle(color: Colors.red, fontSize: 16),
textAlign: TextAlign.center,
),
),
],
),
),
),
),
// Pied de page avec logo et mention copyright
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
bottom: isKeyboardVisible ? 0 : 20,
left: isKeyboardVisible ? 20 : 0,
right: isKeyboardVisible ? 20 : 0,
child: Row(
mainAxisAlignment: isKeyboardVisible
? MainAxisAlignment.spaceBetween
: MainAxisAlignment.center,
children: [
Image.asset(
'lib/assets/images/logolionsdev.png',
height: 30,
),
if (isKeyboardVisible)
Text(
'© 2024 LionsDev',
style: theme.textTheme.bodyMedium!
.copyWith(color: Colors.white70),
textAlign: TextAlign.center,
),
],
),
),
_buildBackground(theme),
_buildContent(theme, isKeyboardVisible),
_buildThemeToggle(theme),
if (_isSubmitting) _buildLoadingOverlay(theme),
],
),
);
}
/// Widget réutilisable pour les champs de texte avec validation et design amélioré
Widget _buildTextFormField({
required String label,
required IconData icon,
bool obscureText = false,
Widget? suffixIcon,
required FormFieldValidator<String> validator,
required FormFieldSetter<String> onSaved,
}) {
final theme = Theme.of(context); // Utilisation du thème global
return TextFormField(
decoration: InputDecoration(
labelText: label,
filled: true,
fillColor: theme.inputDecorationTheme.fillColor,
labelStyle: theme.textTheme.bodyMedium,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
borderSide: BorderSide.none,
/// Construit l'arrière-plan avec dégradé.
Widget _buildBackground(ThemeData theme) {
return AnimatedContainer(
duration: const Duration(seconds: 3),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.secondary,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
prefixIcon: Icon(icon, color: theme.iconTheme.color),
suffixIcon: suffixIcon,
),
obscureText: obscureText,
style: theme.textTheme.bodyLarge,
validator: validator,
onSaved: onSaved,
);
}
/// Construit le contenu principal.
Widget _buildContent(ThemeData theme, bool isKeyboardVisible) {
return FadeTransition(
opacity: _fadeAnimation,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isKeyboardVisible) _buildLogo(theme),
if (!isKeyboardVisible) const SizedBox(height: 32),
_buildTitle(theme),
const SizedBox(height: 32),
_buildLastNameField(theme),
const SizedBox(height: 16),
_buildFirstNameField(theme),
const SizedBox(height: 16),
_buildEmailField(theme),
const SizedBox(height: 16),
_buildPasswordField(theme),
const SizedBox(height: 16),
_buildConfirmPasswordField(theme),
const SizedBox(height: 24),
_buildSubmitButton(theme),
const SizedBox(height: 16),
_buildLoginLink(theme),
if (_errorMessage != null) _buildErrorMessage(theme),
],
),
),
),
),
);
}
/// Construit le logo.
Widget _buildLogo(ThemeData theme) {
return Image.asset(
'lib/assets/images/logo.png',
height: 100,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.person_add,
size: 64,
color: theme.colorScheme.onPrimary,
);
},
);
}
/// Construit le titre.
Widget _buildTitle(ThemeData theme) {
return Text(
'Créer un compte',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
);
}
/// Construit le champ nom.
Widget _buildLastNameField(ThemeData theme) {
return TextFormField(
controller: _lastNameController,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
theme,
'Nom',
Icons.person,
),
style: TextStyle(color: theme.colorScheme.onPrimary),
validator: (value) => Validators.validateName(value, fieldName: 'le nom'),
);
}
/// Construit le champ prénom.
Widget _buildFirstNameField(ThemeData theme) {
return TextFormField(
controller: _firstNameController,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
theme,
'Prénoms',
Icons.person_outline,
),
style: TextStyle(color: theme.colorScheme.onPrimary),
validator: (value) =>
Validators.validateName(value, fieldName: 'le prénom'),
);
}
/// Construit le champ email.
Widget _buildEmailField(ThemeData theme) {
return TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
theme,
'Email',
Icons.email,
),
style: TextStyle(color: theme.colorScheme.onPrimary),
validator: Validators.validateEmail,
);
}
/// Construit le champ mot de passe.
Widget _buildPasswordField(ThemeData theme) {
return TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(
theme,
'Mot de passe',
Icons.lock,
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
color: theme.colorScheme.onPrimary.withOpacity(0.7),
),
onPressed: _togglePasswordVisibility,
),
),
style: TextStyle(color: theme.colorScheme.onPrimary),
validator: (value) => Validators.validatePassword(value, minLength: 6),
);
}
/// Construit le champ confirmation mot de passe.
Widget _buildConfirmPasswordField(ThemeData theme) {
return TextFormField(
controller: _confirmPasswordController,
obscureText: !_isPasswordVisible,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleSubmit(),
decoration: _buildInputDecoration(
theme,
'Confirmer le mot de passe',
Icons.lock_outline,
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
color: theme.colorScheme.onPrimary.withOpacity(0.7),
),
onPressed: _togglePasswordVisibility,
),
),
style: TextStyle(color: theme.colorScheme.onPrimary),
validator: (value) => Validators.validatePasswordMatch(
_passwordController.text,
value,
),
);
}
/// Construit le bouton de soumission (compact).
Widget _buildSubmitButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: CustomButton(
text: 'Créer un compte',
icon: Icons.person_add,
onPressed: () => _handleSubmit(),
isLoading: _isSubmitting,
isEnabled: !_isSubmitting,
variant: ButtonVariant.primary,
size: ButtonSize.medium,
),
);
}
/// Construit le lien vers la connexion.
Widget _buildLoginLink(ThemeData theme) {
return TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Déjà un compte ? Connectez-vous',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onPrimary.withOpacity(0.9),
decoration: TextDecoration.underline,
),
),
);
}
/// Construit le message d'erreur.
Widget _buildErrorMessage(ThemeData theme) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
),
);
}
/// Construit le bouton de basculement de thème.
Widget _buildThemeToggle(ThemeData theme) {
return Positioned(
top: MediaQuery.of(context).padding.top + 8,
right: 16,
child: IconButton(
icon: Icon(
Provider.of<ThemeProvider>(context).isDarkMode
? Icons.light_mode
: Icons.dark_mode,
color: theme.colorScheme.onPrimary,
),
onPressed: () {
Provider.of<ThemeProvider>(context, listen: false).toggleTheme();
},
tooltip: 'Changer le thème',
),
);
}
/// Construit l'overlay de chargement.
Widget _buildLoadingOverlay(ThemeData theme) {
return Container(
color: Colors.black.withOpacity(0.3),
child: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.onPrimary,
),
),
),
);
}
// ============================================================================
// UTILITAIRES
// ============================================================================
/// Construit la décoration d'un champ de texte.
InputDecoration _buildInputDecoration(
ThemeData theme,
String label,
IconData icon, {
Widget? suffixIcon,
}) {
return InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: theme.colorScheme.onPrimary),
suffixIcon: suffixIcon,
filled: true,
fillColor: theme.colorScheme.onPrimary.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.onPrimary.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.onPrimary,
width: 2,
),
),
labelStyle: TextStyle(color: theme.colorScheme.onPrimary),
);
}
}

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

View File

@@ -11,7 +11,7 @@ class StoryScreen extends StatelessWidget {
backgroundColor: Colors.blueAccent,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -27,7 +27,7 @@ class StoryScreen extends StatelessWidget {
filled: true,
fillColor: Colors.white.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10.0),
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
hintStyle: const TextStyle(color: Colors.white70),
@@ -41,7 +41,7 @@ class StoryScreen extends StatelessWidget {
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
),
child: const Text('Publier la Story'),
),