just_bubble 0.0.1
just_bubble: ^0.0.1 copied to clipboard
A flexible and highly customizable bubble widget for Flutter. Easily create chat bubbles, tooltips, or any speech bubble style with custom tails, borders, gradients, images, and shadows.
import 'package:flex_color_picker/flex_color_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:just_bubble/just_bubble.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return CupertinoApp(home: HomePage());
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _bgImages = [
'https://picsum.photos/id/972/400/100.webp?grayscale&blur=2',
'https://picsum.photos/id/299/400/100.webp?grayscale&blur=2',
];
bool _enableBorder = true;
double _borderRadius = 10;
double _borderWidth = 4;
Color? _borderColor1 = Color(0xFF8E2DE2);
Color? _bgColor1,
_bgColor2 = Colors.white,
_borderColor2 = Color(0xFF4A00E0),
_textColor = Color(0xFF4A00E0);
final _shadowColors = <_Wrap<Color?>>[_Wrap(Colors.black26)];
int _tailStyle = 1;
int _tailJoin = 0;
int? _bgImage;
double _tailGap = 5;
BubbleAlignment _tailAlign = BubbleAlignment.bottomLeft;
double _tailAlignValue = 0;
late Tail _tail = Tail.triangle(
edgeGap: _tailGap,
alignment: _tailAlign,
tailJoin: TailJoin.rounded,
);
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: Column(
children: [
const SafeArea(child: SizedBox.shrink()),
_buildBubble(),
_buildOptions(),
],
),
);
}
Widget _buildBubble() {
final image = _bgImage != null
? DecorationImage(
image: NetworkImage(_bgImages[_bgImage!]),
fit: BoxFit.cover,
)
: null;
final borderGradient = _borderColor1 != null && _borderColor2 != null
? LinearGradient(colors: [_borderColor1!, _borderColor2!])
: null;
final shadows = _shadowColors.where((e) => e.color != null).map((e) {
return BoxShadow(blurRadius: 10, offset: Offset(0, 4), color: e.color!);
}).toList();
final gradient = _bgColor1 != null && _bgColor2 != null
? LinearGradient(colors: [_bgColor1!, _bgColor2!])
: null;
final bg = gradient == null ? _bgColor1 ?? _bgColor2 : null;
Widget widget = Bubble(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
gradient: gradient,
color: bg,
image: image,
shadows: shadows,
border: BubbleBorder(
tail: _tail,
color: _borderColor2,
gradient: borderGradient,
borderRadius: BorderRadius.circular(_borderRadius),
width: _enableBorder ? _borderWidth : 0,
),
child: Text(
'Action is the foundational key to all success.',
style: TextStyle(fontSize: 18, color: _textColor),
),
);
return ConstrainedBox(
constraints: BoxConstraints(minHeight: 100),
child: Center(child: widget),
);
}
Widget _buildOptions() {
Widget widget = Column(
children: [
CupertinoFormSection.insetGrouped(
header: Text('Options'),
children: [
CupertinoListTile(
title: Text('Text Color'),
trailing: _buildColorBlock(
color: _textColor,
onPressed: () => _showColorPickerDialog(
_textColor,
(c) => setState(() => _textColor = c),
),
),
),
CupertinoListTile(
title: Text('Background Color'),
trailing: Row(
children: [
_buildColorBlock(
color: _bgColor1,
onPressed: () => _showColorPickerDialog(
_bgColor1,
(c) => setState(() => _bgColor1 = c),
),
),
_buildColorBlock(
color: _bgColor2,
onPressed: () => _showColorPickerDialog(
_bgColor2,
(c) => setState(() => _bgColor2 = c),
),
),
],
),
),
CupertinoListTile(
title: Text('Image'),
trailing: Row(
children: List.generate(
_bgImages.length,
(index) => _buildImageBlock(
_bgImages[index],
selected: _bgImage == index,
onPressed: () => _onSelectImageBlock(index),
),
),
),
),
],
),
CupertinoFormSection.insetGrouped(
header: Text('Border'),
children: [
CupertinoListTile(
title: Text('Enable'),
trailing: CupertinoSwitch(
value: _enableBorder,
onChanged: (value) => setState(() => _enableBorder = value),
),
),
],
),
AnimatedAlign(
heightFactor: _enableBorder ? 1 : 0,
alignment: Alignment.topCenter,
duration: kThemeAnimationDuration,
child: CupertinoFormSection.insetGrouped(
children: [
CupertinoListTile(
title: Text('Color'),
trailing: Row(
children: [
_buildColorBlock(
color: _borderColor1,
onPressed: () => _showColorPickerDialog(
_borderColor1,
(c) => setState(() => _borderColor1 = c),
),
),
_buildColorBlock(
color: _borderColor2,
onPressed: () => _showColorPickerDialog(
_borderColor2,
(c) => setState(() => _borderColor2 = c),
),
),
],
),
),
CupertinoListTile(
title: Text('Width'),
additionalInfo: Text(_borderWidth.toStringAsFixed(1)),
trailing: CupertinoSlider(
value: _borderWidth,
min: 0,
max: 20,
onChanged: (v) => setState(() => _borderWidth = v),
),
),
CupertinoListTile(
title: Text('Radius'),
additionalInfo: Text(_borderRadius.toStringAsFixed(1)),
trailing: CupertinoSlider(
value: _borderRadius,
min: 0,
max: 20,
onChanged: (v) => setState(() => _borderRadius = v),
),
),
],
),
),
CupertinoFormSection.insetGrouped(
header: Text('Shadow'),
children: [
CupertinoListTile(
title: Text('Add'),
trailing: CupertinoButton(
child: Icon(CupertinoIcons.add),
onPressed: () {
setState(() => _shadowColors.add(_Wrap(null)));
},
),
),
...List.generate(
_shadowColors.length,
(index) => Dismissible(
key: Key('shadow ${_shadowColors[index].hashCode}'),
onUpdate: _dismissibleHaptic,
onDismissed: (_) {
setState(() => _shadowColors.removeAt(index));
},
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 16),
child: Text('Remove', style: TextStyle(color: Colors.white)),
),
direction: DismissDirection.endToStart,
child: CupertinoListTile(
title: Text('Shadow $index'),
trailing: _buildColorBlock(
color: _shadowColors[index].color,
onPressed: () => _showColorPickerDialog(
_shadowColors[index].color,
(c) => setState(() => _shadowColors[index].color = c),
),
),
),
),
),
],
),
CupertinoFormSection.insetGrouped(
header: Text('Tail'),
children: [
CupertinoListTile(
title: Text('Preset Style'),
trailing: CupertinoSlidingSegmentedControl<int>(
children: {0: Text('None'), 1: Text('Triangle')},
groupValue: _tailStyle,
onValueChanged: (value) => setState(() {
_tailStyle = value!;
_applyTail();
}),
),
),
],
),
ClipRect(
child: AnimatedAlign(
heightFactor: _tailStyle != 0 ? 1 : 0,
alignment: Alignment.topCenter,
duration: kThemeAnimationDuration,
child: CupertinoFormSection.insetGrouped(
children: [
CupertinoListTile(
title: Text('Alignment'),
trailing: Flexible(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(
BubbleAlignment.values.length,
(index) => _buildBubbleAlignBlock(
BubbleAlignment.values[index],
selected:
_tailAlign == BubbleAlignment.values[index],
onPressed: () => setState(() {
_tailAlign = BubbleAlignment.values[index];
_applyTail();
}),
),
),
),
),
),
),
CupertinoListTile(
title: Text('Tail Join'),
trailing: CupertinoSlidingSegmentedControl<int>(
children: {0: Text('Sharp'), 1: Text('Rounded')},
groupValue: _tailJoin,
onValueChanged: (value) => setState(() {
_tailJoin = value!;
_applyTail();
}),
),
),
CupertinoListTile(
title: Text('Custom Alignment'),
additionalInfo: Text(_tailAlignValue.toStringAsFixed(1)),
trailing: CupertinoSlider(
value: _tailAlignValue,
min: -1,
max: 1,
onChanged: (v) => setState(() {
_tailAlign = BubbleAlignment(_tailAlign.direction, v);
_applyTail();
}),
),
),
CupertinoListTile(
title: Text('EdgeGap'),
additionalInfo: Text(_tailGap.toStringAsFixed(1)),
trailing: CupertinoSlider(
value: _tailGap,
min: 0,
max: 20,
onChanged: (v) => setState(() {
_tailGap = v;
_applyTail();
}),
),
),
],
),
),
),
],
);
return Flexible(
child: ColoredBox(
color: CupertinoColors.systemGroupedBackground,
child: SingleChildScrollView(child: widget),
),
);
}
Widget _buildColorBlock({Color? color, VoidCallback? onPressed}) {
return CupertinoButton(
onPressed: onPressed,
sizeStyle: CupertinoButtonSize.small,
child: SizedBox.square(
dimension: 28,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
border: Border.all(color: Colors.black12),
borderRadius: BorderRadius.all(Radius.circular(2)),
),
),
),
);
}
Widget _buildBubbleAlignBlock(
BubbleAlignment align, {
VoidCallback? onPressed,
bool selected = false,
}) {
final borderSide = BorderSide(
color: selected ? Colors.black45 : Colors.black12,
);
final activeBorderSide = const BorderSide(color: Colors.black, width: 2);
return CupertinoButton(
onPressed: onPressed,
sizeStyle: CupertinoButtonSize.small,
child: SizedBox.square(
dimension: 28,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: align.direction == BubbleDirection.top
? activeBorderSide
: borderSide,
right: align.direction == BubbleDirection.right
? activeBorderSide
: borderSide,
bottom: align.direction == BubbleDirection.bottom
? activeBorderSide
: borderSide,
left: align.direction == BubbleDirection.left
? activeBorderSide
: borderSide,
),
),
child: Align(
alignment: align.alignment,
child: SizedBox.square(
dimension: 10,
child: ColoredBox(color: Colors.black),
),
),
),
),
);
}
Widget _buildImageBlock(
String url, {
VoidCallback? onPressed,
bool selected = false,
}) {
const borderRadius = BorderRadius.all(Radius.circular(2));
Widget child = ClipRRect(
borderRadius: borderRadius,
child: Image.network(
url,
cacheWidth: 28,
width: 28,
height: 28,
fit: BoxFit.cover,
),
);
if (selected) {
child = DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
borderRadius: borderRadius,
border: Border.all(),
),
child: child,
);
}
return CupertinoButton(
onPressed: onPressed,
sizeStyle: CupertinoButtonSize.small,
child: child,
);
}
void _applyTail() {
final tailJoin = switch (_tailJoin) {
1 => TailJoin.rounded,
_ => TailJoin.sharp,
};
_tail = switch (_tailStyle) {
1 => Tail.triangle(
edgeGap: _tailGap,
alignment: _tailAlign,
tailJoin: tailJoin,
),
_ => const Tail.none(),
};
_tailAlignValue = _tailAlign.v;
}
void _onSelectImageBlock(int? index) {
if (_bgImage == index) index = null;
setState(() => _bgImage = index);
}
void _dismissibleHaptic(details) {
if (details.reached && !details.previousReached ||
!details.reached && details.previousReached) {
HapticFeedback.lightImpact();
}
}
void _showColorPickerDialog(Color? color, ValueSetter<Color> onColorChanged) {
setState(() {});
return;
showCupertinoModalPopup(
context: context,
builder: (context) => CupertinoPopupSurface(
child: ColorPicker(
mainAxisSize: MainAxisSize.min,
color: color ?? Colors.blue,
onColorChanged: onColorChanged,
pickersEnabled: {
ColorPickerType.both: false,
ColorPickerType.primary: false,
ColorPickerType.accent: false,
ColorPickerType.bw: false,
ColorPickerType.custom: false,
ColorPickerType.customSecondary: false,
ColorPickerType.wheel: true,
},
),
),
);
}
}
class _Wrap<T> {
T color;
_Wrap(this.color);
}