Chat bubbles with day dividers
Message list with sent and received bubbles, grouped day separators, per-message time and RTL-safe alignment.
FlutterCardsflutterchatmessageslistview
Code
import 'package:flutter/material.dart';
class ChatMessage {
const ChatMessage(this.text, this.sentAt, {required this.mine});
final String text;
final DateTime sentAt;
final bool mine;
}
class ChatView extends StatelessWidget {
const ChatView({super.key, required this.messages});
final List<ChatMessage> messages; // oldest first
@override
Widget build(BuildContext context) {
final loc = MaterialLocalizations.of(context);
return ListView.builder(
reverse: true, // newest at the bottom, keeps position when the keyboard opens
padding: const EdgeInsets.all(12),
itemCount: messages.length,
itemBuilder: (context, i) {
final index = messages.length - 1 - i;
final m = messages[index];
final newDay = index == 0 || !DateUtils.isSameDay(messages[index - 1].sentAt, m.sentAt);
return Column(children: [
if (newDay)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(loc.formatMediumDate(m.sentAt), style: Theme.of(context).textTheme.labelMedium),
),
_Bubble(message: m, time: loc.formatTimeOfDay(TimeOfDay.fromDateTime(m.sentAt))),
]);
},
);
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.message, required this.time});
final ChatMessage message;
final String time;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final mine = message.mine;
const r = Radius.circular(16);
return Align(
// Directional values flip automatically for right-to-left locales.
alignment: mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 2),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
decoration: BoxDecoration(
color: mine ? scheme.primaryContainer : scheme.surfaceContainerHigh,
borderRadius: BorderRadiusDirectional.only(
topStart: r,
topEnd: r,
bottomStart: mine ? r : Radius.zero,
bottomEnd: mine ? Radius.zero : r,
),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [
Align(alignment: AlignmentDirectional.centerStart, child: Text(message.text)),
const SizedBox(height: 2),
Text(time, style: Theme.of(context).textTheme.labelSmall),
]),
),
),
);
}
}
How it works
What it solves
A chat screen is mostly layout detail: which side each message sits on, where the small tail goes, how long messages wrap, and where day labels appear. Hard-coded left and right values break in right-to-left languages.
How it works
The list is reversed so the newest message sits at the bottom and the scroll position stays anchored when the keyboard opens. For each item the code converts the reversed index back to the natural one and compares its date with the previous message; when the day differs, a date label is inserted above it.
Alignment and corner radii use the directional variants (AlignmentDirectional, BorderRadiusDirectional), so sent bubbles hug the end edge in both writing directions. Date and time text comes from MaterialLocalizations, which respects the locale and 24 hour setting.
Adapting it
Add status ticks beside the time for sending, sent and read states, or wrap the bubble in GestureDetector for copy menus. For long histories, pair this with the paginated list snippet and load older messages when the user reaches the top of the reversed list.
Pitfalls
- Messages must be sorted oldest first; the day-divider check assumes it.
- Local timestamps are used for grouping, so store UTC and convert before building the model.
- Very long unbroken strings need
softWrap; the default wrapping handles normal text.
How to use it
Copy chat_view.dart to lib/chat/. No packages.
ChatView(messages: [
ChatMessage('Hello', DateTime.now(), mine: false),
ChatMessage('Hi there', DateTime.now(), mine: true),
])
Place a composer row beneath it inside a Column with Expanded.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.