Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active April 2, 2025 09:26
Show Gist options
  • Save PlugFox/30097d608f08dfd97e8dc7589bda4f07 to your computer and use it in GitHub Desktop.
Save PlugFox/30097d608f08dfd97e8dc7589bda4f07 to your computer and use it in GitHub Desktop.
Chat bubbles with input field V2
/*
* Chat bubbles with input field V2
* https://gist.github.com/PlugFox/30097d608f08dfd97e8dc7589bda4f07
* https://dartpad.dev?id=30097d608f08dfd97e8dc7589bda4f07
* Mike Matiunin <[email protected]>, 02 April 2025
*/
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show Tooltip;
import 'package:flutter/widgets.dart';
void main() {
final input = TextEditingController(text: 'Hello, World!');
runApp(App(input: input));
}
class App extends StatelessWidget {
const App({required this.input, super.key});
final TextEditingController input;
@override
Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr,
child: DefaultTextStyle(
style: const TextStyle(fontSize: 18, color: Color(0xFF000000)),
child: Overlay(
clipBehavior: Clip.none,
initialEntries: <OverlayEntry>[
// Chat bubbles
OverlayEntry(
canSizeOverlay: false,
maintainState: true,
opaque: false,
builder:
(context) => Positioned.fill(
child: SingleChildScrollView(
padding: const EdgeInsets.only(top: 24 + 64 + 24),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: RepaintBoundary(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
ChatBubble.l(input: input, author: 'Alice'),
ChatBubble.r(input: input, author: 'Bob'),
],
),
),
),
),
),
),
),
// Input field
OverlayEntry(
canSizeOverlay: false,
maintainState: true,
opaque: false,
builder:
(context) => Padding(
padding: const EdgeInsets.all(24),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 350,
maxHeight: 64 + 24,
),
child: ChatInputField(controller: input),
),
),
),
),
],
),
),
);
}
enum ChatBubbleType { left, right }
class ChatBubble extends StatelessWidget {
const ChatBubble.l({
required String author,
required ValueListenable<TextEditingValue> input,
Key? key,
}) : this._(
author: author,
type: ChatBubbleType.left,
painter: const _ChatBubblePainter(type: ChatBubbleType.left),
outerPadding: const EdgeInsets.fromLTRB(0, 8, 16, 8),
bubblePadding: const EdgeInsets.fromLTRB(12, 0, 0, 0),
input: input,
key: key,
);
const ChatBubble.r({
required String author,
required ValueListenable<TextEditingValue> input,
Key? key,
}) : this._(
author: author,
type: ChatBubbleType.right,
painter: const _ChatBubblePainter(type: ChatBubbleType.right),
outerPadding: const EdgeInsets.fromLTRB(16, 8, 0, 8),
bubblePadding: const EdgeInsets.fromLTRB(0, 0, 12, 0),
input: input,
key: key,
);
const ChatBubble._({
required this.author,
required this.type,
required this.painter,
required this.outerPadding,
required this.bubblePadding,
required this.input,
super.key,
});
final String author;
final ChatBubbleType type;
final EdgeInsets outerPadding;
final EdgeInsets bubblePadding;
final ValueListenable<TextEditingValue> input;
final CustomPainter painter;
@override
Widget build(BuildContext context) => Align(
alignment: switch (type) {
ChatBubbleType.left => Alignment.topLeft,
ChatBubbleType.right => Alignment.topRight,
},
child: Padding(
padding: outerPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: switch (type) {
ChatBubbleType.left => CrossAxisAlignment.start,
ChatBubbleType.right => CrossAxisAlignment.end,
},
textDirection: TextDirection.ltr,
verticalDirection: VerticalDirection.down,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
// Chat bubble
Padding(
padding: bubblePadding,
child: CustomPaint(
isComplex: false,
willChange: false,
painter: painter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Stack(
fit: StackFit.loose,
alignment: AlignmentDirectional.topStart,
children: <Widget>[
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
textDirection: TextDirection.ltr,
verticalDirection: VerticalDirection.down,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
// Author
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 4),
child: Text(
author,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF263238),
fontSize: 16,
height: 1,
fontWeight: FontWeight.w800,
),
),
),
/* const Divider(
height: 8,
thickness: 1,
color: Color(0x7F000000),
), */
// Message
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: input,
builder:
(context, value, _) => Text.rich(
TextSpan(
children: <InlineSpan>[
TextSpan(text: value.text),
const WidgetSpan(
child: Visibility.maintain(
visible: false,
child: SizedBox(
width: 56,
height: 12,
),
),
),
],
),
),
),
),
],
),
const Positioned.fill(
child: Align(
alignment: Alignment.bottomRight,
child: DateTimeWidget(),
),
),
],
),
),
),
),
// Avatar
Padding(
padding: const EdgeInsets.only(top: 12),
child: Tooltip(
message: author,
child: DecoratedBox(
position: DecorationPosition.background,
decoration: const ShapeDecoration(
color: Color(0xFF000000),
shape: CircleBorder(
side: BorderSide(color: Color(0xffffffff), width: 2),
),
),
child: SizedBox.square(
dimension: 24,
child: Center(
child: Text(
switch (author.trim()) {
String text when text.isNotEmpty =>
text.substring(0, 1).toUpperCase(),
_ => '?',
},
style: const TextStyle(
color: Color(0xFFFFFFFF),
fontSize: 18,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.normal,
height: 1,
),
),
),
),
),
),
),
],
),
),
);
}
class DateTimeWidget extends StatelessWidget {
const DateTimeWidget({super.key});
static final ValueNotifier<String> _currentTimeNotifier = () {
String evalDateTime() {
final DateTime(hour: h, minute: m, second: s) = DateTime.now();
return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
final notifier = ValueNotifier<String>(evalDateTime());
Timer.periodic(
const Duration(seconds: 1),
(_) => _currentTimeNotifier.value = evalDateTime(),
);
return notifier;
}();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(left: 8),
child: ValueListenableBuilder<String>(
valueListenable: _currentTimeNotifier,
builder:
(context, value, _) => Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF263238),
fontSize: 12,
height: 1,
),
),
),
);
}
class ChatInputField extends StatefulWidget {
const ChatInputField({required this.controller, super.key});
final TextEditingController controller;
@override
State<ChatInputField> createState() => _ChatInputFieldState();
}
class _ChatInputFieldState extends State<ChatInputField> {
final FocusNode _focusNode = FocusNode();
@override
Widget build(BuildContext context) => DecoratedBox(
position: DecorationPosition.background,
decoration: ShapeDecoration(
color: const Color(0xFFcfd8dc),
shape: RoundedRectangleBorder(
side: const BorderSide(color: Color(0xFF90a4ae), width: 1),
borderRadius: BorderRadius.circular(5),
),
),
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: EditableText(
autofocus: true,
controller: widget.controller,
focusNode: _focusNode,
backgroundCursorColor: const Color(0xFF000000),
cursorColor: const Color(0xFF000000),
style: DefaultTextStyle.of(context).style,
textAlign: TextAlign.left,
minLines: 1,
maxLines: 3,
autocorrect: true,
cursorRadius: const Radius.circular(2),
cursorHeight: 18,
cursorWidth: 4,
showSelectionHandles: true,
selectionColor: const Color(0xFF90a4ae),
keyboardType: TextInputType.multiline,
onSubmitted: (_) => FocusScope.of(context).unfocus(),
),
),
),
);
}
class _ChatBubblePainter extends CustomPainter {
const _ChatBubblePainter({required this.type});
static final Paint _leftPaint =
Paint()
..color = const Color(0xFFef5350)
..style = PaintingStyle.fill
..blendMode = BlendMode.src
..isAntiAlias = true;
static final Paint _rightPaint =
Paint()
..color = const Color(0xFF42a5f5)
..style = PaintingStyle.fill
..blendMode = BlendMode.src
..isAntiAlias = true;
final ChatBubbleType type;
@override
void paint(Canvas canvas, Size size) {
const radius = 12.0;
const tailSize = 10.0;
canvas.save();
switch (type) {
case ChatBubbleType.left:
// Do nothing
break;
case ChatBubbleType.right:
// Flip the canvas horizontally
canvas
..translate(size.width, 0)
..scale(-1, 1);
break;
}
final path =
Path()
..moveTo(tailSize * 2, size.height)
..lineTo(tailSize, size.height + tailSize)
..lineTo(tailSize * 3, size.height)
..lineTo(size.width - radius, size.height)
..quadraticBezierTo(
size.width,
size.height,
size.width,
size.height - radius,
)
..lineTo(size.width, radius)
..quadraticBezierTo(size.width, 0, size.width - radius, 0)
..lineTo(radius, 0)
..quadraticBezierTo(0, 0, 0, radius)
..lineTo(0, size.height - radius)
..quadraticBezierTo(0, size.height, tailSize * 2, size.height)
..close();
canvas
..drawPath(path, switch (type) {
ChatBubbleType.left => _leftPaint,
ChatBubbleType.right => _rightPaint,
})
..restore();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment