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:
513
lib/presentation/screens/chat/chat_screen.dart
Normal file
513
lib/presentation/screens/chat/chat_screen.dart
Normal 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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
464
lib/presentation/screens/chat/conversations_screen.dart
Normal file
464
lib/presentation/screens/chat/conversations_screen.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
));
|
||||
),);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 d’amis',
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
544
lib/presentation/screens/profile/edit_profile_screen.dart
Normal file
544
lib/presentation/screens/profile/edit_profile_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user