import 'package:flutter/material.dart'; import '/data/models/message_model.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:provider/provider.dart'; import 'dart:typed_data'; import '/core/theme_manager.dart'; import '/core/constants.dart'; class MessageBubble extends StatefulWidget { final MessageModel message; final VoidCallback? onTap; final VoidCallback? onReplyTap; final VoidCallback? onImageTap; final Future Function(MessageModel)? onImageNeeded; const MessageBubble({ super.key, required this.message, this.onTap, this.onReplyTap, this.onImageTap, this.onImageNeeded, }); @override State createState() => _MessageBubbleState(); } class _MessageBubbleState extends State { Uint8List? _imageBytes; @override void initState() { super.initState(); if (widget.message.localFileBytes == null && widget.message.messageType == MessageType.image && widget.onImageNeeded != null) { _loadImage(); } } Future _loadImage() async { final bytes = await widget.onImageNeeded!(widget.message); if (mounted) { setState(() { _imageBytes = bytes; }); } } @override Widget build(BuildContext context) { final isMe = widget.message.isMe; final themeProv = context.watch(); return Align( // Выравниваем вправо, если это мое сообщение, и влево — если чужое alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Material( color: Colors.transparent, child: InkWell( onTap: widget.onTap, // На телефонах иногда удобнее/надежнее long-press (как в мессенджерах), // поэтому поддерживаем оба жеста. onLongPress: widget.onTap, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isMe ? 16 : 0), bottomRight: Radius.circular(isMe ? 0 : 16), ), child: Container( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), constraints: BoxConstraints( // Чтобы баббл не растягивался на весь экран, если текста мало maxWidth: MediaQuery.of(context).size.width * 0.75, ), decoration: BoxDecoration( color: isMe ? Theme.of(context).colorScheme.primaryFixedDim : Colors.grey[300], borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), // Скругляем углы по-разному для "хвостика" сообщения bottomLeft: Radius.circular(isMe ? 16 : 0), bottomRight: Radius.circular(isMe ? 0 : 16), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (widget.message.replyToText != null) ...[ GestureDetector( onTap: widget.onReplyTap, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.only(bottom: 4), decoration: BoxDecoration( color: (isMe ? Colors.white : Colors.black).withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border( left: BorderSide( color: isMe ? Colors.black54 : Colors.black38, width: 2, ), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.reply, size: 14, color: isMe ? Colors.black54 : Colors.black54, ), const SizedBox(width: 4), Expanded( child: Text( widget.message.replyToText!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: isMe ? const Color.fromARGB(221, 21, 21, 21) : const Color.fromARGB(221, 21, 21, 21), fontSize: 12, fontStyle: FontStyle.italic, ), ), ), ], ), ), ), ], if (widget.message.messageType == MessageType.image) ...[ GestureDetector( onTap: widget.onImageTap, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: (_imageBytes ?? widget.message.localFileBytes) != null ? Image.memory( _imageBytes ?? widget.message.localFileBytes!, width: 200, height: 200, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( width: 200, height: 200, color: Colors.grey[300], child: const Icon(Icons.broken_image, size: 50), ); }, ) : Container( width: 200, height: 200, color: Colors.grey[300], child: const Center( child: CircularProgressIndicator(), ), ), ), ), if (widget.message.text.isNotEmpty) ...[ const SizedBox(height: 8), ], ], if (widget.message.messageType == MessageType.text || widget.message.text.isNotEmpty) ...[ Linkify( onOpen: (link) async { final Uri url = Uri.parse(link.url); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { throw Exception('Could not launch $url'); } }, text: widget.message.text, style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black), linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold), ), ], const SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatTime(widget.message.createdAt), style: TextStyle( color: isMe ? Colors.black87 : Colors.black54, fontSize: 10, ), ), if (widget.message.editedAt != null) ...[ const SizedBox(width: 6), Text( '(изменено)', style: TextStyle( color: isMe ? Colors.black54 : Colors.black54, fontSize: 10, fontStyle: FontStyle.italic, ), ), ], if (isMe) ...[ const SizedBox(width: 6), Icon( _statusIcon(widget.message.status), size: 12, color: _statusColor(widget.message.status, isMe), ), ], ], ), ], ), ), ), ), ); } IconData _statusIcon(MessageStatus status) { switch (status) { case MessageStatus.sending: return Icons.access_time; case MessageStatus.sent: return Icons.done; case MessageStatus.delivered: return Icons.done_all; case MessageStatus.read: return Icons.done_all; case MessageStatus.failed: return Icons.error; } } Color _statusColor(MessageStatus status, bool isMe) { switch (status) { case MessageStatus.read: return isMe ? Colors.blue : Colors.blue; case MessageStatus.failed: return Colors.red; default: return isMe ? Colors.white70 : Colors.black54; } } String _formatTime(DateTime time) { final hour = time.hour.toString().padLeft(2, '0'); final minute = time.minute.toString().padLeft(2, '0'); return '$hour:$minute'; } }