Files
m8chat-app2/lib/features/chat/presentation/message_bubble.dart
help4bis d2a2a1ab07 feat(ui): mobile UI v2 — contrast, avatars, room-list density, compose FAB, gradient login (v1.8.0+12)
From a 7-agent design workflow (3 lenses judged + synthesised). Shipped the
high-impact/low-risk tier:
- message bubbles: self #1265ED (WCAG AA pass), incoming #1C2452 + hairline
- deterministic per-user avatar colours (8-hue brand palette)
- room tile: brighter unread preview, compact density, aligned timestamp
- FAB -> New message (thumb zone), explore -> AppBar
- login + welcome brand gradient, card outlines
- input maxLines cap, bubble max-width viewport fraction, AppBar hairline

All verified on production build in headless Chromium at mobile size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:49:53 +10:00

338 lines
9.4 KiB
Dart

// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
// 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 '../../../app/theme.dart';
import '../../../shared/widgets/matrix_avatar.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) ...[
MatrixAvatar(
name: message.senderDisplayName,
avatarUrl: message.senderAvatarUrl,
radius: 16,
),
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 _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
? M8Colours.selfBubble
: M8Colours.darkSurfaceVariant;
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
final maxBubbleWidth =
(MediaQuery.sizeOf(context).width.clamp(0, 560) * 0.78).toDouble();
return Container(
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
decoration: BoxDecoration(
color: bgColour,
border: isMine
? null
: Border.all(color: theme.colorScheme.outline.withAlpha(31)),
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,
),
),
MessageType.encrypted => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_outline, size: 14, color: textColour.withAlpha(153)),
const SizedBox(width: 6),
Flexible(
child: Text(
'Encrypted. Use Element to read',
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) {
final url = message.mediaUrl;
if (url == null) return const Icon(Icons.image_not_supported);
return GestureDetector(
onTap: () => _openFullscreen(context, url, message.body),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url,
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)),
),
),
),
);
}
void _openFullscreen(BuildContext context, String url, String? caption) {
Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => _ImageViewer(url: url, caption: caption),
),
);
}
}
class _ImageViewer extends StatelessWidget {
const _ImageViewer({required this.url, this.caption});
final String url;
final String? caption;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: caption != null
? Text(
caption!,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
)
: null,
),
body: Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4,
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.contain,
placeholder: (_, __) => const CircularProgressIndicator(
color: Colors.white,
),
errorWidget: (_, __, ___) => const Icon(
Icons.broken_image,
color: Colors.white,
size: 64,
),
),
),
),
);
}
}
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,
});
static final DateFormat _timeFormat = DateFormat('HH:mm');
final DateTime timestamp;
final bool isEdited;
final Color textColour;
@override
Widget build(BuildContext context) {
final formatted = _timeFormat.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<String, List<String>> 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(),
),
);
}
}