880 lines
32 KiB
Dart
880 lines
32 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' as io;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_giphy_picker/giphy_ui.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'dart:convert';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'dart:io';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
class ChatScreen extends StatefulWidget {
|
|
final String usernameExpediteur;
|
|
|
|
const ChatScreen({super.key, required this.usernameExpediteur});
|
|
|
|
@override
|
|
_ChatScreenState createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
final TextEditingController _controller = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final FocusNode _focusNode = FocusNode();
|
|
|
|
List<Map<String, dynamic>> messages = [];
|
|
String? expediteur;
|
|
late String destinataire;
|
|
int idConversation = 0;
|
|
bool _isButtonEnabled = false;
|
|
late Timer _pollingTimer;
|
|
bool _isInitialLoading = true;
|
|
bool _showScrollToBottom = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
destinataire = widget.usernameExpediteur;
|
|
_loadExpediteur();
|
|
_controller.addListener(_updateButtonState);
|
|
_startPolling();
|
|
_scrollController.addListener(() {
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final currentScroll = _scrollController.offset;
|
|
final threshold = 100.0;
|
|
|
|
final shouldShow = (maxScroll - currentScroll) > threshold;
|
|
|
|
if (shouldShow != _showScrollToBottom) {
|
|
setState(() {
|
|
_showScrollToBottom = shouldShow;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
void _startPolling() {
|
|
_pollingTimer = Timer.periodic(Duration(seconds: 5), (timer) {
|
|
_fetchMessages();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pollingTimer.cancel();
|
|
_controller.removeListener(_updateButtonState);
|
|
_controller.dispose();
|
|
_focusNode.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> searchId() async {
|
|
try {
|
|
final uri =
|
|
Uri.parse('https://nexuschat.derickexm.be/conversation/get_id/')
|
|
.replace(queryParameters: {
|
|
'user1': expediteur!,
|
|
'user2': destinataire,
|
|
});
|
|
final response = await http.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
final jsonResponse = jsonDecode(response.body);
|
|
if (jsonResponse is Map<String, dynamic> &&
|
|
jsonResponse.containsKey('conversations')) {
|
|
List<dynamic> conversations = jsonResponse['conversations'];
|
|
if (conversations.isNotEmpty && conversations[0].containsKey('id')) {
|
|
int? idConv = int.tryParse(conversations[0]['id'].toString());
|
|
if (idConv != null) {
|
|
setState(() {
|
|
idConversation = idConv;
|
|
});
|
|
_fetchMessages();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur searchId: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _checkConv() async {
|
|
if (expediteur == null) return;
|
|
try {
|
|
final uri =
|
|
Uri.parse('https://nexuschat.derickexm.be/conversation/check_conv/')
|
|
.replace(queryParameters: {
|
|
'user1': expediteur!,
|
|
'user2': destinataire,
|
|
});
|
|
final response = await http.get(uri);
|
|
if (response.statusCode == 200) {
|
|
final jsonResponse = jsonDecode(response.body);
|
|
if (jsonResponse['exists'] == true) {
|
|
searchId();
|
|
} else {
|
|
await _createConv();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('Erreur checkConv: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _createConv() async {
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('https://nexuschat.derickexm.be/conversation/create_conv/'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'user1': expediteur,
|
|
'user2': destinataire,
|
|
}),
|
|
);
|
|
if (response.statusCode == 200) {
|
|
final jsonResponse = jsonDecode(response.body);
|
|
if (jsonResponse.containsKey('id_conversation')) {
|
|
int? idConv = jsonResponse['id_conversation'];
|
|
if (idConv != null) {
|
|
setState(() {
|
|
idConversation = idConv;
|
|
});
|
|
_fetchMessages();
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('Erreur createConv: $e');
|
|
}
|
|
}
|
|
|
|
Future<Map<String, String>> _chiffrerMessage(String message) async {
|
|
final uri =
|
|
Uri.parse('https://nexuschat.derickexm.be/messages/crypt_message/')
|
|
.replace(queryParameters: {'message': message});
|
|
final response = await http.post(uri);
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
print("Chiffrement → encrypted_message: ${data['encrypted_message']}");
|
|
print("Chiffrement → key: ${data['key']}");
|
|
return {
|
|
'encrypted_message': data['encrypted_message'] ?? message,
|
|
'key': data['key'] ?? ''
|
|
};
|
|
} else {
|
|
print("Erreur chiffrement: ${response.body}");
|
|
return {'encrypted_message': message, 'key': ''};
|
|
}
|
|
}
|
|
|
|
Future<String> _dechiffrerMessage(String encryptedMessage, String key) async {
|
|
final uri =
|
|
Uri.parse('https://nexuschat.derickexm.be/messages/uncrypt_messages/');
|
|
final response = await http.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
"messages": [
|
|
{"encrypted_message": encryptedMessage, "key": key}
|
|
]
|
|
}),
|
|
);
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
if (data['results'] != null &&
|
|
data['results'] is List &&
|
|
data['results'].isNotEmpty) {
|
|
final first = data['results'][0];
|
|
return first['decrypted_message'] ?? encryptedMessage;
|
|
} else {
|
|
return encryptedMessage;
|
|
}
|
|
} else {
|
|
print("Erreur déchiffrement: ${response.body}");
|
|
return encryptedMessage;
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchMessages() async {
|
|
if (idConversation <= 0) return;
|
|
try {
|
|
final uri =
|
|
Uri.parse('https://nexuschat.derickexm.be/messages/get_message/')
|
|
.replace(queryParameters: {'id_conv': idConversation.toString()});
|
|
final response = await http.get(uri);
|
|
if (response.statusCode == 200) {
|
|
final jsonResponse = jsonDecode(response.body);
|
|
if (jsonResponse.containsKey('messages')) {
|
|
final List<dynamic> messagesList = jsonResponse['messages'];
|
|
|
|
final batch = messagesList
|
|
.map((msg) => {
|
|
"encrypted_message": msg['messages'].toString(),
|
|
"key": msg['key']?.toString() ?? ''
|
|
})
|
|
.toList();
|
|
|
|
final decryptUri = Uri.parse(
|
|
'https://nexuschat.derickexm.be/messages/uncrypt_messages/');
|
|
final decryptResponse = await http.post(
|
|
decryptUri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({"messages": batch}),
|
|
);
|
|
|
|
List<String> textesDecryptes = [];
|
|
|
|
if (decryptResponse.statusCode == 200) {
|
|
final decryptedData = jsonDecode(decryptResponse.body);
|
|
if (decryptedData['results'] is List) {
|
|
textesDecryptes = List<String>.from(decryptedData['results']
|
|
.map((item) => item['decrypted_message'] ?? ''));
|
|
}
|
|
}
|
|
|
|
final List<Map<String, dynamic>> finalMessages = [];
|
|
for (int i = 0; i < messagesList.length; i++) {
|
|
final msg = messagesList[i];
|
|
final text = i < textesDecryptes.length
|
|
? textesDecryptes[i]
|
|
: msg['messages'].toString();
|
|
finalMessages.add({
|
|
'sender': msg['expediteur'].toString(),
|
|
'text': text,
|
|
'encrypted': msg['messages'].toString(),
|
|
'sent_at': msg['sent_at'].toString(),
|
|
'key': msg['key']?.toString() ?? '',
|
|
'type': msg['type'] ?? 'text',
|
|
});
|
|
print(msg['sent_at'].toString());
|
|
}
|
|
|
|
setState(() {
|
|
messages = finalMessages;
|
|
_isInitialLoading = false;
|
|
});
|
|
_scrollToBottom();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('Erreur fetchMessages: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _supprimerMessage(Map<String, dynamic> message) async {
|
|
try {
|
|
final uri = Uri.parse(
|
|
'https://nexuschat.derickexm.be/messages/messages/delete_message/');
|
|
final response = await http.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'expediteur': expediteur,
|
|
'id_conversation': idConversation,
|
|
'message': message['encrypted'],
|
|
'key': message['key'],
|
|
}),
|
|
);
|
|
if (response.statusCode == 200) {
|
|
print(" Message supprimé");
|
|
_fetchMessages(); // refresh
|
|
} else {
|
|
print("Erreur suppression: ${response.body}");
|
|
}
|
|
} catch (e) {
|
|
print(" Erreur _supprimerMessage: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _sendnotification(String messageText) async {
|
|
if (destinataire.isEmpty) return;
|
|
|
|
final url =
|
|
Uri.parse("https://nexuschat.derickexm.be/users/send_notifications");
|
|
final body = jsonEncode({
|
|
'destinataire': destinataire,
|
|
'type': 'text',
|
|
'contenu': messageText,
|
|
'owner': expediteur
|
|
});
|
|
|
|
try {
|
|
final response = await http.post(url,
|
|
headers: {'Content-Type': 'application/json'}, body: body);
|
|
if (response.statusCode == 200) {
|
|
print("✅ Notification envoyée.");
|
|
} else {
|
|
print("❌ Erreur envoi notification: ${response.body}");
|
|
}
|
|
} catch (e) {
|
|
print("❌ Exception envoi notification: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _loadExpediteur() async {
|
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
String? expediteurEmail =
|
|
prefs.getString('user_email') ?? widget.usernameExpediteur;
|
|
try {
|
|
final uri =
|
|
Uri.parse('https://nexuschat.derickexm.be/users/get_username/')
|
|
.replace(queryParameters: {'email': expediteurEmail});
|
|
final response = await http.get(uri);
|
|
if (response.statusCode == 200) {
|
|
final jsonResponse = jsonDecode(response.body);
|
|
if (jsonResponse.containsKey('username')) {
|
|
setState(() {
|
|
expediteur = jsonResponse['username'];
|
|
});
|
|
print("📦 Chargement expediteur : $expediteur");
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('Erreur loadExpediteur: $e');
|
|
} finally {
|
|
_checkConv();
|
|
}
|
|
}
|
|
|
|
void _updateButtonState() {
|
|
setState(() {
|
|
_isButtonEnabled = _controller.text.trim().isNotEmpty;
|
|
});
|
|
}
|
|
|
|
Future<void> sendMessage(String message) async {
|
|
print("🧪 Appel à sendMessage()...");
|
|
print("🔍 expediteur: $expediteur");
|
|
if (expediteur == null || message.trim().isEmpty) return;
|
|
|
|
final cryptoData = await _chiffrerMessage(message.trim());
|
|
final encryptedMessage = cryptoData['encrypted_message'] ?? '';
|
|
final key = cryptoData['key'] ?? '';
|
|
final plainText = _controller.text.trim();
|
|
|
|
// --- MODIFIED LINE FOR TIMESTAMP ---
|
|
// Get current local time, then convert to UTC, then add 2 hours (for CEST / UTC+2)
|
|
// This ensures the time sent is what CEST time would be for this moment.
|
|
final nowCestTime = DateTime.now().toUtc().add(Duration(hours: 2));
|
|
final nowCestIsoFormatted =
|
|
DateFormat("yyyy-MM-dd HH:mm:ss").format(nowCestTime);
|
|
|
|
if (key.isEmpty) {
|
|
print('❌ Clé de chiffrement manquante. Message non envoyé.');
|
|
return;
|
|
}
|
|
|
|
// Affichage local temporaire (sera remplacé par le polling)
|
|
setState(() {
|
|
messages = List<Map<String, dynamic>>.from(messages)
|
|
..add({
|
|
'sender': expediteur ?? '',
|
|
'text': plainText,
|
|
'encrypted': encryptedMessage,
|
|
'timestamp': 'En cours...', // Temporaire, will be updated by polling
|
|
'key': key,
|
|
'type': 'text'
|
|
});
|
|
});
|
|
_controller.clear();
|
|
_updateButtonState();
|
|
_scrollToBottom();
|
|
|
|
print("✉️ Envoi du message chiffré : $encryptedMessage");
|
|
print("🔑 Clé : $key");
|
|
print(
|
|
"⏰ Timestamp envoyé à l'API (CEST): $nowCestIsoFormatted"); // Add this debug print
|
|
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('https://nexuschat.derickexm.be/messages/send_message/'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'expediteur': expediteur,
|
|
'destinataire': destinataire,
|
|
'message': encryptedMessage,
|
|
'id_conversation': idConversation,
|
|
'key': key,
|
|
'type': 'text',
|
|
'timestamp':
|
|
nowCestIsoFormatted, // --- USE THE CEST FORMATTED STRING ---
|
|
}),
|
|
);
|
|
|
|
print("📡 Status Code: ${response.statusCode}");
|
|
print("📡 Response: ${response.body}");
|
|
|
|
if (response.statusCode == 200) {
|
|
print("✅ Message envoyé avec succès");
|
|
|
|
await Future.delayed(Duration(milliseconds: 500));
|
|
await _fetchMessages();
|
|
|
|
final jsonResponse = jsonDecode(response.body);
|
|
if (jsonResponse.containsKey('reply')) {
|
|
setState(() {
|
|
messages.add({
|
|
'sender': destinataire,
|
|
'text': jsonResponse['reply'],
|
|
'timestamp': 'À l\'instant',
|
|
'type': 'text'
|
|
});
|
|
});
|
|
_scrollToBottom();
|
|
}
|
|
await _sendnotification(message.trim());
|
|
} else {
|
|
print('❌ Erreur HTTP ${response.statusCode}: ${response.body}');
|
|
setState(() {
|
|
messages.removeLast();
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur exception sendMessage: $e');
|
|
setState(() {
|
|
messages.removeLast();
|
|
});
|
|
}
|
|
}
|
|
|
|
// 🎭 Fonction _sendGif simplifiée
|
|
Future<void> _sendGif(String gifUrl) async {
|
|
if (expediteur == null || gifUrl.isEmpty) return;
|
|
|
|
print('📤 Préparation à l\'envoi du GIF : $gifUrl');
|
|
final nowCestTime = DateTime.now().toUtc().add(Duration(hours: 2));
|
|
final nowCestIsoFormatted =
|
|
DateFormat("yyyy-MM-dd HH:mm:ss").format(nowCestTime);
|
|
|
|
final cryptoData = await _chiffrerMessage(gifUrl);
|
|
final encryptedMessage = cryptoData['encrypted_message'] ?? gifUrl;
|
|
final key = cryptoData['key'] ?? 'test';
|
|
|
|
// Affichage local temporaire
|
|
setState(() {
|
|
messages = List<Map<String, dynamic>>.from(messages)
|
|
..add({
|
|
'sender': expediteur ?? '',
|
|
'text': gifUrl,
|
|
'encrypted': encryptedMessage,
|
|
'sent_at': 'En cours...',
|
|
'key': key,
|
|
'type': 'gif'
|
|
});
|
|
});
|
|
_scrollToBottom();
|
|
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('https://nexuschat.derickexm.be/messages/send_message/'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'expediteur': expediteur,
|
|
'destinataire': destinataire,
|
|
'message': encryptedMessage,
|
|
'id_conversation': idConversation,
|
|
'key': key,
|
|
'type': 'gif',
|
|
'timestamp': nowCestIsoFormatted, // --- INCLUDE THIS IN THE BODY ---
|
|
}),
|
|
);
|
|
|
|
print("📡 GIF Status: ${response.statusCode}");
|
|
|
|
if (response.statusCode == 200) {
|
|
print('✅ GIF envoyé avec succès.');
|
|
// Refresh pour récupérer le vrai timestamp
|
|
await Future.delayed(Duration(milliseconds: 500));
|
|
await _fetchMessages();
|
|
} else {
|
|
print('❌ Erreur envoi GIF : ${response.body}');
|
|
setState(() {
|
|
messages.removeLast();
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur réseau envoi GIF : $e');
|
|
setState(() {
|
|
messages.removeLast();
|
|
});
|
|
}
|
|
}
|
|
|
|
void _scrollToBottom({bool force = false}) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients) {
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final currentScroll = _scrollController.offset;
|
|
final threshold = 100.0;
|
|
|
|
if (force || (maxScroll - currentScroll) < threshold) {
|
|
_scrollController.animateTo(
|
|
maxScroll,
|
|
duration: Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _sendFile(String fileUrl) async {
|
|
if (expediteur == null || fileUrl.isEmpty) return;
|
|
|
|
final nowUtcIso = DateTime.now().toIso8601String();
|
|
final cryptoData = await _chiffrerMessage(fileUrl);
|
|
final encryptedMessage = cryptoData['encrypted_message'] ?? fileUrl;
|
|
final key = cryptoData['key'] ?? 'test';
|
|
|
|
setState(() {
|
|
messages = List<Map<String, dynamic>>.from(messages)
|
|
..add({
|
|
'sender': expediteur ?? '',
|
|
'text': fileUrl,
|
|
'encrypted': encryptedMessage,
|
|
'timestamp': nowUtcIso,
|
|
'key': key,
|
|
'type': 'file'
|
|
});
|
|
});
|
|
_scrollToBottom();
|
|
|
|
final body = jsonEncode({
|
|
'expediteur': expediteur,
|
|
'destinataire': destinataire,
|
|
'message': encryptedMessage,
|
|
// 'timestamp': nowUtcIso, // removed
|
|
'id_conversation': idConversation,
|
|
'key': key,
|
|
'type': 'file'
|
|
});
|
|
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('https://nexuschat.derickexm.be/messages/send_message/'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: body,
|
|
);
|
|
|
|
if (response.statusCode != 200) {
|
|
print('Erreur envoi fichier : ${response.body}');
|
|
} else {
|
|
print('✅ Fichier envoyé avec succès.');
|
|
}
|
|
} catch (e) {
|
|
print('Erreur réseau envoi fichier : $e');
|
|
}
|
|
}
|
|
|
|
///
|
|
|
|
Future<void> _selectGif() async {
|
|
GiphyLocale? fr;
|
|
fr ??= GiphyLocale.fromContext(context);
|
|
|
|
final config = GiphyUIConfig(
|
|
apiKey: 'qG62ngUKbr66l2jVPcDGulJW1RbZy5xI',
|
|
);
|
|
final result =
|
|
await showGiphyPicker(context, config, locale: GiphyLocale.fr);
|
|
|
|
if (result != null) {
|
|
print("GIF sélectionné : ${result.url}");
|
|
_sendGif(result.url);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
title: Text(destinataire),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
_isInitialLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, index) {
|
|
final message = messages[index];
|
|
final isMe = message['sender'] == expediteur;
|
|
return Container(
|
|
alignment: isMe
|
|
? Alignment.centerRight
|
|
: Alignment.centerLeft,
|
|
margin: const EdgeInsets.symmetric(
|
|
vertical: 4, horizontal: 8),
|
|
child: Column(
|
|
crossAxisAlignment: isMe
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
message['sender'] ?? '',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: GestureDetector(
|
|
onLongPress: isMe
|
|
? () {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(
|
|
"Supprimer le message ?"),
|
|
actions: [
|
|
TextButton(
|
|
child:
|
|
Text("Annuler"),
|
|
onPressed: () =>
|
|
Navigator.of(
|
|
context)
|
|
.pop(),
|
|
),
|
|
TextButton(
|
|
child:
|
|
Text("Supprimer"),
|
|
onPressed: () {
|
|
Navigator.of(
|
|
context)
|
|
.pop();
|
|
_supprimerMessage(
|
|
message);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
: null,
|
|
child: Container(
|
|
margin: const EdgeInsets.only(top: 2),
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: isMe
|
|
? Colors.orange
|
|
: Colors.grey[300],
|
|
borderRadius:
|
|
BorderRadius.circular(10),
|
|
),
|
|
child: message['type'] == 'gif'
|
|
? Image.network(
|
|
message['text'] ?? '',
|
|
errorBuilder: (context, error,
|
|
stackTrace) {
|
|
return Text(
|
|
"Erreur de chargement du GIF");
|
|
})
|
|
: message['text']
|
|
.toString()
|
|
.startsWith('http')
|
|
? GestureDetector(
|
|
onTap: () => launchUrl(
|
|
Uri.parse(
|
|
message['text'])),
|
|
child: Text(
|
|
'📎 Fichier joint',
|
|
style: TextStyle(
|
|
decoration:
|
|
TextDecoration
|
|
.underline,
|
|
color:
|
|
Colors.blueAccent,
|
|
),
|
|
),
|
|
)
|
|
: Text(
|
|
message['owner'] != null
|
|
? "Message de ${message['owner']}"
|
|
: (message['text'] ??
|
|
''),
|
|
style: TextStyle(
|
|
color: isMe
|
|
? Colors.white
|
|
: Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Builder(
|
|
builder: (context) {
|
|
final rawDateStr = message['sent_at'] ??
|
|
message['timestamp'];
|
|
DateTime? parsedDate;
|
|
try {
|
|
parsedDate =
|
|
DateTime.tryParse(rawDateStr ?? '');
|
|
} catch (_) {}
|
|
final display = parsedDate != null
|
|
? DateFormat("d MMMM yyyy à HH:mm",
|
|
"fr_FR")
|
|
.format(parsedDate)
|
|
: '';
|
|
return Text(
|
|
display,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey[600]),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
focusNode: _focusNode,
|
|
style: TextStyle(fontSize: 16),
|
|
cursorColor: Colors.orange,
|
|
decoration: InputDecoration(
|
|
hintText: 'Entrez votre message...',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
minLines: 1,
|
|
maxLines: 5,
|
|
keyboardType: TextInputType.multiline,
|
|
textInputAction: TextInputAction.newline,
|
|
onChanged: (text) => _updateButtonState(),
|
|
onEditingComplete: () {},
|
|
inputFormatters: [
|
|
_EnterKeyFormatter(
|
|
onEnter: () {
|
|
if (_controller.text
|
|
.trim()
|
|
.isNotEmpty) {
|
|
sendMessage(_controller.text);
|
|
_controller.clear();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 5),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade100,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(Icons.gif_box,
|
|
color: Colors.deepOrange, size: 28),
|
|
onPressed: _selectGif,
|
|
tooltip: 'GIF',
|
|
),
|
|
),
|
|
SizedBox(width: 5),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(Icons.send, color: Colors.black),
|
|
onPressed: _isButtonEnabled
|
|
? () {
|
|
sendMessage(_controller.text);
|
|
_controller.clear();
|
|
_updateButtonState();
|
|
}
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4, top: 2),
|
|
child: Text(
|
|
"🔒 Messages chiffrés de bout en bout",
|
|
style:
|
|
TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_showScrollToBottom)
|
|
Positioned(
|
|
bottom: 100,
|
|
right: 16,
|
|
child: FloatingActionButton(
|
|
mini: true,
|
|
backgroundColor: Colors.orange,
|
|
child: Icon(Icons.arrow_downward),
|
|
onPressed: () => _scrollToBottom(force: true),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ✅ Formatter : Enter = envoi / Shift+Enter = saut de ligne
|
|
class _EnterKeyFormatter extends TextInputFormatter {
|
|
final VoidCallback onEnter;
|
|
|
|
_EnterKeyFormatter({required this.onEnter});
|
|
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue, TextEditingValue newValue) {
|
|
if (newValue.text.length > oldValue.text.length &&
|
|
newValue.text.endsWith('\n') &&
|
|
!RawKeyboard.instance.keysPressed
|
|
.contains(LogicalKeyboardKey.shiftLeft) &&
|
|
!RawKeyboard.instance.keysPressed
|
|
.contains(LogicalKeyboardKey.shiftRight)) {
|
|
onEnter();
|
|
return const TextEditingValue(text: ''); // vide le champ après envoi
|
|
}
|
|
return newValue;
|
|
}
|
|
}
|