Files
geo/app/lib/chat/pages/chat_page.dart
pierre 570a1fa1f0 feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API
- Mise à jour VERSION vers 3.3.4
- Optimisations et révisions architecture API (deploy-api.sh, scripts de migration)
- Ajout documentation Stripe Tap to Pay complète
- Migration vers polices Inter Variable pour Flutter
- Optimisations build Android et nettoyage fichiers temporaires
- Amélioration système de déploiement avec gestion backups
- Ajout scripts CRON et migrations base de données

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 20:11:15 +02:00

580 lines
21 KiB
Dart
Executable File

import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/message.dart';
import '../services/chat_service.dart';
/// Page simple de chat
class ChatPage extends StatefulWidget {
final String roomId;
final String roomTitle;
final String? roomType; // Type de room (private, group, broadcast)
final int? roomCreatorId; // ID du créateur pour les rooms broadcast
final bool isEmbedded; // Pour indiquer si on est en mode split-view web
const ChatPage({
super.key,
required this.roomId,
required this.roomTitle,
this.roomType,
this.roomCreatorId,
this.isEmbedded = false,
});
@override
State<ChatPage> createState() => ChatPageState();
}
class ChatPageState extends State<ChatPage> {
final _service = ChatService.instance;
final _messageController = TextEditingController();
final _scrollController = ScrollController();
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMore = true;
int _lastMessageCount = 0;
// Vérifier si c'est une room broadcast et si l'utilisateur peut poster
bool get _isBroadcast => widget.roomType == 'broadcast';
bool get _canSendMessage => !_isBroadcast ||
(widget.roomCreatorId != null && widget.roomCreatorId == _service.currentUserId);
@override
void initState() {
super.initState();
_loadInitialMessages(); // L'API marque automatiquement comme lu lors du chargement des messages
}
Future<void> _loadInitialMessages() async {
setState(() => _isLoading = true);
debugPrint('🚀 ChatPage: Chargement initial des messages pour room ${widget.roomId}');
final result = await _service.getMessages(widget.roomId, isInitialLoad: true);
setState(() {
_hasMore = result['has_more'] as bool;
_isLoading = false;
});
// Attendre un peu avant de scroller pour laisser le temps au ListView de se construire
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
}
Future<void> _loadMoreMessages() async {
if (_isLoadingMore || !_hasMore) return;
// Récupérer le message le plus ancien dans Hive pour cette room
final currentMessages = _service.messagesBox.values
.where((m) => m.roomId == widget.roomId)
.toList()
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
if (currentMessages.isEmpty) return;
setState(() => _isLoadingMore = true);
final oldestMessageId = currentMessages.first.id;
final result = await _service.getMessages(widget.roomId, beforeMessageId: oldestMessageId);
setState(() {
_hasMore = result['has_more'] as bool;
_isLoadingMore = false;
});
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty) return;
_messageController.clear();
await _service.sendMessage(widget.roomId, text);
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
// Si on est en mode embedded (web split-view), pas de Scaffold/AppBar
if (widget.isEmbedded) {
return Container(
color: const Color(0xFFFAFAFA),
child: Column(
children: [
// En-tête simple pour le mode embedded
Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: Row(
children: [
if (_isBroadcast) ...[
Icon(Icons.campaign, size: 20, color: Colors.amber.shade600),
const SizedBox(width: 8),
],
Expanded(
child: Text(
widget.roomTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
if (_isBroadcast)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'ANNONCE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade800,
),
),
),
],
),
),
// Contenu du chat
Expanded(
child: _buildChatContent(context),
),
],
),
);
}
// Mode normal avec Scaffold pour mobile
return Scaffold(
backgroundColor: const Color(0xFFFAFAFA),
appBar: AppBar(
title: Row(
children: [
if (_isBroadcast) ...[
Icon(Icons.campaign, size: 20, color: Colors.amber.shade600),
const SizedBox(width: 8),
],
Expanded(
child: Text(
widget.roomTitle,
overflow: TextOverflow.ellipsis,
),
),
if (_isBroadcast)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'ANNONCE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade800,
),
),
),
],
),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1E293B),
elevation: 0,
),
body: _buildChatContent(context),
);
}
Widget _buildChatContent(BuildContext context) {
return Stack(
children: [
Column(
children: [
// Messages
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: ValueListenableBuilder<Box<Message>>(
valueListenable: _service.messagesBox.listenable(),
builder: (context, box, _) {
// Récupérer tous les messages de cette room depuis Hive
final allMessages = box.values
.where((m) => m.roomId == widget.roomId)
.toList()
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
debugPrint('🔍 ChatPage: ${allMessages.length} messages trouvés pour room ${widget.roomId}');
if (allMessages.isEmpty) {
debugPrint('📭 Aucun message dans Hive pour cette room');
debugPrint('📦 Total messages dans Hive: ${box.length}');
final roomIds = box.values.map((m) => m.roomId).toSet();
debugPrint('🏠 Rooms dans Hive: $roomIds');
} else {
// Détecter les doublons potentiels
final messageIds = <String>{};
final duplicates = <String>[];
for (final msg in allMessages) {
if (messageIds.contains(msg.id)) {
duplicates.add(msg.id);
}
messageIds.add(msg.id);
}
if (duplicates.isNotEmpty) {
debugPrint('⚠️ DOUBLONS DÉTECTÉS: $duplicates');
}
// Afficher les IDs des messages pour débugger
debugPrint('📝 Liste des messages:');
for (final msg in allMessages) {
debugPrint(' - ${msg.id}: "${msg.content.substring(0, msg.content.length > 20 ? 20 : msg.content.length)}..." (isMe: ${msg.isMe})');
}
}
// Détecter les nouveaux messages et scroller vers le bas
if (allMessages.length > _lastMessageCount) {
_lastMessageCount = allMessages.length;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollToBottom();
}
});
}
if (allMessages.isEmpty) {
return Center(
child: Text(
'Aucun message',
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
),
);
}
return Column(
children: [
// Bouton "Charger plus" en haut
if (_hasMore)
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _isLoadingMore
? const SizedBox(
height: 40,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
: TextButton.icon(
onPressed: _loadMoreMessages,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Charger plus de messages'),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF2563EB),
),
),
),
// Liste des messages avec pull-to-refresh
Expanded(
child: RefreshIndicator(
onRefresh: _loadInitialMessages,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: allMessages.length,
itemBuilder: (context, index) {
final message = allMessages[index];
return _MessageBubble(
message: message,
isBroadcast: _isBroadcast,
);
},
),
),
),
],
);
},
),
),
// Input ou message broadcast
if (_canSendMessage)
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey[200]!),
),
),
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: _isBroadcast ? 'Nouvelle annonce...' : 'Message...',
hintStyle: TextStyle(color: Colors.grey[400]),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
IconButton(
icon: Icon(_isBroadcast ? Icons.campaign : Icons.send),
color: _isBroadcast ? Colors.amber.shade600 : const Color(0xFF2563EB),
onPressed: _sendMessage,
),
],
),
)
else
Container(
decoration: BoxDecoration(
color: Colors.amber.shade50,
border: Border(
top: BorderSide(color: Colors.amber.shade200),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.campaign, size: 20, color: Colors.amber.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
'Ceci est une annonce officielle. Seul l\'administrateur peut poster des messages.',
style: TextStyle(
fontSize: 13,
color: Colors.amber.shade800,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
],
),
],
);
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
}
/// Widget simple pour une bulle de message
class _MessageBubble extends StatelessWidget {
final Message message;
final bool isBroadcast;
const _MessageBubble({
required this.message,
this.isBroadcast = false,
});
@override
Widget build(BuildContext context) {
final isMe = message.isMe;
// Style spécial pour les messages broadcast
if (isBroadcast) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
decoration: BoxDecoration(
color: Colors.amber.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// En-tête broadcast
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(11),
topRight: Radius.circular(11),
),
),
child: Row(
children: [
Icon(Icons.campaign, size: 18, color: Colors.amber.shade700),
const SizedBox(width: 8),
Text(
'ANNONCE OFFICIELLE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.amber.shade800,
letterSpacing: 0.5,
),
),
const Spacer(),
Text(
_formatTime(message.sentAt),
style: TextStyle(
fontSize: 11,
color: Colors.amber.shade700,
),
),
],
),
),
// Contenu du message
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMe)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'De: ${_getFullName(message)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.amber.shade900,
),
),
),
Text(
message.content,
style: const TextStyle(
fontSize: 15,
height: 1.4,
),
),
],
),
),
],
),
);
}
// Style normal pour les messages privés/groupe
// Vérifier si le message est synchronisé
final isSynced = message.isSynced;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isMe ? const Color(0xFFEFF6FF) : Colors.white,
borderRadius: BorderRadius.circular(8),
border: !isMe ? Border.all(color: Colors.grey[300]!) : null,
),
child: Column(
crossAxisAlignment:
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (!isMe)
Text(
_getFullName(message),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF2563EB),
),
),
const SizedBox(height: 2),
Text(
message.content,
style: TextStyle(
fontSize: 14,
fontStyle: isSynced ? FontStyle.normal : FontStyle.italic,
color: isSynced ? null : Colors.grey[600],
),
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.sentAt),
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
),
),
if (!isSynced) ...[
const SizedBox(width: 4),
Icon(
Icons.schedule,
size: 12,
color: Colors.orange[400],
),
],
],
),
],
),
),
);
}
String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
String _getFullName(Message message) {
// Construire le nom complet avec prénom + nom
// sender_first_name contient le prénom (ex: "Pierre")
// sender_name contient le nom de famille (ex: "VALERY ADM")
final firstName = message.senderFirstName ?? '';
final lastName = message.senderName;
if (firstName.isNotEmpty) {
return '$firstName $lastName';
}
return lastName;
}
}