Last active
April 2, 2025 09:26
-
-
Save PlugFox/30097d608f08dfd97e8dc7589bda4f07 to your computer and use it in GitHub Desktop.
Chat bubbles with input field V2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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