// Version: 1.0.0 | Created: 2026-04-01 // Message bubble widget. Handles text, images, files, redacted, replies. import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../domain/message_model.dart'; class MessageBubble extends StatelessWidget { const MessageBubble({super.key, required this.message}); final MessageModel message; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isMine = message.isMine; return Padding( padding: EdgeInsets.only( top: 2, bottom: 2, left: isMine ? 48 : 8, right: isMine ? 8 : 48, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ if (!isMine) ...[ _SenderAvatar(message: message), const SizedBox(width: 8), ], Flexible( child: Column( crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (!isMine) Padding( padding: const EdgeInsets.only(left: 4, bottom: 2), child: Text( message.senderDisplayName, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), _BubbleContent(message: message, isMine: isMine), if (message.reactions.isNotEmpty) _ReactionsRow(reactions: message.reactions), ], ), ), ], ), ); } } class _SenderAvatar extends StatelessWidget { const _SenderAvatar({required this.message}); final MessageModel message; @override Widget build(BuildContext context) { final initials = message.senderDisplayName.isNotEmpty ? message.senderDisplayName[0].toUpperCase() : '?'; if (message.senderAvatarUrl != null) { return CircleAvatar( radius: 16, backgroundImage: CachedNetworkImageProvider(message.senderAvatarUrl!), ); } return CircleAvatar( radius: 16, backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(51), child: Text( initials, style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, ), ), ); } } class _BubbleContent extends StatelessWidget { const _BubbleContent({required this.message, required this.isMine}); final MessageModel message; final bool isMine; @override Widget build(BuildContext context) { final theme = Theme.of(context); final bgColour = isMine ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest; final textColour = isMine ? Colors.white : theme.colorScheme.onSurface; return Container( constraints: const BoxConstraints(maxWidth: 320), decoration: BoxDecoration( color: bgColour, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isMine ? 16 : 4), bottomRight: Radius.circular(isMine ? 4 : 16), ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ _MessageContentBody(message: message, textColour: textColour), const SizedBox(height: 2), _Timestamp( timestamp: message.timestamp, isEdited: message.isEdited, textColour: textColour.withAlpha(153), ), ], ), ), ); } } class _MessageContentBody extends StatelessWidget { const _MessageContentBody({required this.message, required this.textColour}); final MessageModel message; final Color textColour; @override Widget build(BuildContext context) { return switch (message.type) { MessageType.text => Text( message.body ?? '', style: TextStyle(color: textColour), ), MessageType.image => _ImageContent(message: message), MessageType.file => _FileContent( message: message, textColour: textColour, ), MessageType.redacted => Text( 'This message was deleted.', style: TextStyle( color: textColour.withAlpha(153), fontStyle: FontStyle.italic, ), ), _ => Text( message.body ?? 'Unsupported message type', style: TextStyle( color: textColour.withAlpha(153), fontStyle: FontStyle.italic, ), ), }; } } class _ImageContent extends StatelessWidget { const _ImageContent({required this.message}); final MessageModel message; @override Widget build(BuildContext context) { if (message.mediaUrl != null) { return ClipRRect( borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: message.mediaUrl!, width: 240, fit: BoxFit.cover, placeholder: (_, __) => const SizedBox( height: 160, child: Center(child: CircularProgressIndicator()), ), errorWidget: (_, __, ___) => const SizedBox( height: 80, child: Center(child: Icon(Icons.broken_image)), ), ), ); } return const Icon(Icons.image_not_supported); } } class _FileContent extends StatelessWidget { const _FileContent({required this.message, required this.textColour}); final MessageModel message; final Color textColour; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.attach_file, color: textColour, size: 18), const SizedBox(width: 6), Flexible( child: Text( message.body ?? 'File', style: TextStyle(color: textColour), overflow: TextOverflow.ellipsis, ), ), ], ); } } class _Timestamp extends StatelessWidget { const _Timestamp({ required this.timestamp, required this.isEdited, required this.textColour, }); final DateTime timestamp; final bool isEdited; final Color textColour; @override Widget build(BuildContext context) { final formatted = DateFormat('HH:mm').format(timestamp.toLocal()); final label = isEdited ? '$formatted (edited)' : formatted; return Text(label, style: TextStyle(fontSize: 10, color: textColour)); } } class _ReactionsRow extends StatelessWidget { const _ReactionsRow({required this.reactions}); final Map> reactions; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.only(top: 4), child: Wrap( spacing: 4, children: reactions.entries.map((entry) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.outline.withAlpha(51), ), ), child: Text( '${entry.key} ${entry.value.length}', style: const TextStyle(fontSize: 12), ), ); }).toList(), ), ); } }