if you find this project useful, consider Buy Me A Coffee, Thank You.
import 'package:flutter/material.dart'
show
Animation,
AnimationController,
AppBar,
Border,
BorderRadius,
BoxDecoration,
BuildContext,
Canvas,
Center,
Color,
Colors,
Column,
Container,
CrossAxisAlignment,
CurvedAnimation,
Curves,
CustomPaint,
CustomPainter,
EdgeInsets,
FadeTransition,
GlobalKey,
Icon,
IconButton,
Icons,
InkWell,
MainAxisAlignment,
MainAxisSize,
Material,
MaterialApp,
MediaQuery,
Offset,
Overlay,
OverlayEntry,
Padding,
Paint,
PaintingStyle,
Path,
PathFillType,
Positioned,
RRect,
Radius,
Rect,
RenderBox,
Row,
Scaffold,
Size,
SizedBox,
SlideTransition,
Spacer,
Stack,
State,
StatefulWidget,
StatelessWidget,
Text,
TextStyle,
ThemeData,
TickerProviderStateMixin,
Tween,
Widget,
runApp;
import 'dart:async' show Future, Timer;
import 'package:flutter/foundation.dart' show kDebugMode;
/// A simple example showing how to use ExplainFeaturesTutorial.
/// this is the application root,
void main() {
runApp(
MaterialApp(
home: const Home(),
theme: ThemeData.light(),
title: 'Explain Features Tutorial',
debugShowCheckedModeBanner: false,
),
);
}
/// this is the home, where all the code is defined.
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final GlobalKey appBarKey = GlobalKey();
final GlobalKey body1Key = GlobalKey();
final GlobalKey body2Key = GlobalKey();
final GlobalKey bottomNav = GlobalKey();
late final List<GlobalKey> allKeys = <GlobalKey>[
appBarKey,
body2Key,
body1Key,
bottomNav,
];
@override
void initState() {
ExplainFeaturesTutorial(
widgetKeys: allKeys,
widgetExplainerText: const <String>[
'This is the app bar action button',
'Visit Site text on the body',
'First Widget on the body',
'Widget on bottom navigation bar for help',
],
context: context,
).showTutorial(delayInSeconds: 10);
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: <Widget>[
IconButton(
key: appBarKey,
onPressed: () {},
icon: const Icon(Icons.local_activity),
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
key: body1Key,
'Developing is fun and cool',
style: const TextStyle(fontSize: 20),
),
Text(
key: body2Key,
'Visit my site for work or help: https://king-kibugenza.web.app/',
style: const TextStyle(fontSize: 20),
),
],
),
),
bottomNavigationBar: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
key: bottomNav,
'Reach out for more help',
style: const TextStyle(fontSize: 20),
),
],
),
);
}
}
//! package starts from here,
/// this constant defines the bubble height,
const double _bubbleHeight = 170;
/// defines the bubble width
const double _bubbleWidth = 270;
/// This class displays a tutorial overlay highlighting widgets by their GlobalKeys.
/// It draws a spotlight around the target and shows text explaining its purpose.
///
/// this should be called from initState, in a stateful widget
class ExplainFeaturesTutorial {
ExplainFeaturesTutorial({
required List<GlobalKey<State<StatefulWidget>>> widgetKeys,
required List<String> widgetExplainerText,
required BuildContext context,
String cancelText = 'Cancel',
String next = 'Next',
bool showCancelButton = true,
bool disableButtonDelayAnimations = false,
String lastButtonText = 'Okay',
Color targetObserverColor = const Color.fromARGB(255, 171, 71, 188),
}) : _lastButtonText = lastButtonText,
_disableButtonDelayAnimations = disableButtonDelayAnimations,
_context = context,
_targetObserverColor = targetObserverColor,
_next = next,
_showCancelButton = showCancelButton,
_cancelText = cancelText,
_widgetExplainerText = widgetExplainerText,
_widgetKeys = widgetKeys;
/// List of keys pointing to widgets that will be focused one by one.
final List<GlobalKey> _widgetKeys;
/// Corresponding explainer texts shown with each widget.
final List<String> _widgetExplainerText;
/// Text for the cancel button.
final String _cancelText;
/// Whether to show the cancel button.
final bool _showCancelButton;
/// Text for next,
final String _next;
/// target observer color, like the bubble and the decoration,
final Color _targetObserverColor;
/// build context of the current UI,
final BuildContext _context;
/// bool to disable button Delay animations,
final bool _disableButtonDelayAnimations;
/// last button text,
final String _lastButtonText;
/// overlay variable
OverlayEntry? _overlayEntry;
/// step incrementer,
int _step = 0;
/// Inserts an overlay for the current step.
///
/// and delays by 5 seconds to show up, please increment according to your needs.
///
/// set it to 0 for no delay
void showTutorial({int delayInSeconds = 2}) async {
await Future<void>.delayed(Duration(seconds: delayInSeconds));
_overlayEntry = _buildOverlay();
// ignore: use_build_context_synchronously
Overlay.of(_context).insert(_overlayEntry!);
}
/// Advances to the next step, or cancels if requested or finished.
void _nextStep({bool cancel = false}) {
_overlayEntry?.remove();
_step++;
if (cancel || _step == _widgetKeys.length) {
_overlayEntry?.dispose();
if (kDebugMode) {
/// the 32m is a green color for vscode, and the last om codes, is the resettor.
/// to prevent everything turning green in your terminal.
print(
'\x1B[32m Successfully disposed the resources \x1B[0m',
);
}
return;
}
showTutorial(delayInSeconds: 0);
}
/// Builds the current step's overlay entry.
OverlayEntry? _buildOverlay() {
final BuildContext? currentContext = _widgetKeys[_step].currentContext;
// Handle case where key context is not found.
if (currentContext == null) {
if (kDebugMode) {
print(
'\x1B[31m context is null, check if the widget is in the tree properly \x1B[0m',
);
}
return null;
}
final RenderBox targetBox = currentContext.findRenderObject() as RenderBox;
final Offset targetOffset = targetBox.localToGlobal(Offset.zero);
final Size targetSize = targetBox.size;
return OverlayEntry(
builder: (_) {
/// last overlay,
final bool notLastElement = _step != (_widgetKeys.length - 1);
return Material(
color: Colors.transparent,
child: Stack(
children: <Widget>[
// Dim the screen and spotlight the widget
_SpotlightOverlay(
targetOffset: targetOffset,
targetSize: targetSize,
),
// Highlighted border around the current widget
Positioned(
left: targetOffset.dx - 8,
top: targetOffset.dy - 8,
child: InkWell(
onTap: _nextStep,
child: Container(
width: targetSize.width + 16,
height: targetSize.height + 16,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.transparent,
border: Border.all(
color: _targetObserverColor,
width: 3,
),
),
),
),
),
/// bubble to show on the message,
_messageBubble(
context: _context,
targetOffset: targetOffset,
targetSize: targetSize,
targetObserverColor: _targetObserverColor,
child: SizedBox(
height: _bubbleHeight,
width: _bubbleWidth,
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_widgetExplainerText.elementAtOrNull(_step) ?? '',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
),
maxLines: 4,
),
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// only show if it's true else next will be used alone,
if (notLastElement && _showCancelButton) ...<Widget>[
_AnimateViews(
disableAnimations: _disableButtonDelayAnimations,
delayInMilliseconds: 500,
child: InkWell(
onTap: () => _nextStep(cancel: true),
child: Container(
padding: const EdgeInsets.all(6.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.red.withOpacity(0.3),
),
child: Text(
_cancelText,
style: const TextStyle(
fontSize: 20,
color: Colors.white,
),
),
),
),
),
const SizedBox(width: 10),
],
_AnimateViews(
disableAnimations: _disableButtonDelayAnimations,
delayInMilliseconds: 300,
child: InkWell(
onTap: _nextStep,
child: Container(
padding: const EdgeInsets.all(6.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.blue.withOpacity(0.3),
),
child: Text(
notLastElement ? _next : _lastButtonText,
style: const TextStyle(
fontSize: 20,
color: Colors.white,
),
),
),
),
),
],
),
const SizedBox(height: 12),
],
),
),
),
],
),
);
},
);
}
}
/// A simple widget that adds a fade and slide-in animation to its child.
/// Used internally by the tutorial overlay to animate widgets into view.
class _AnimateViews extends StatefulWidget {
const _AnimateViews({
required this.delayInMilliseconds,
required this.disableAnimations,
required this.child,
});
/// widget to animate,
final Widget child;
/// delay in milliseconds,
final int delayInMilliseconds;
/// bool to disable animations,
final bool disableAnimations;
@override
_AnimateViewsState createState() => _AnimateViewsState();
}
class _AnimateViewsState extends State<_AnimateViews>
with TickerProviderStateMixin {
late AnimationController _animController;
late Animation<Offset> _animOffset;
Timer? timer;
@override
void initState() {
super.initState();
if (widget.disableAnimations) return;
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
final CurvedAnimation curve = CurvedAnimation(
curve: Curves.decelerate,
parent: _animController,
);
_animOffset =
Tween<Offset>(begin: const Offset(0.0, 0.35), end: Offset.zero)
.animate(curve);
// Delay the animation to create a staggered appearance effect.
timer = Timer(Duration(milliseconds: widget.delayInMilliseconds), () {
_animController.forward();
});
}
@override
void dispose() {
// don't dispose the animation because it was never initialised...
// if this flag was true
if (widget.disableAnimations) return;
timer?.cancel();
_animController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
/// immediately return if animations is disabled,
if (widget.disableAnimations) return widget.child;
return FadeTransition(
opacity: _animController,
child: SlideTransition(
position: _animOffset,
child: widget.child,
),
);
}
}
/// A custom widget that paints a dark overlay with a cut-out highlight
/// around the current widget being focused.
class _SpotlightOverlay extends StatelessWidget {
const _SpotlightOverlay({
required this.targetOffset,
required this.targetSize,
});
final Offset targetOffset;
final Size targetSize;
@override
Widget build(BuildContext context) => CustomPaint(
size: MediaQuery.of(context).size,
painter: _SpotlightPainter(targetOffset, targetSize),
);
}
/// The painter responsible for drawing the spotlight effect.
class _SpotlightPainter extends CustomPainter {
_SpotlightPainter(this.targetOffset, this.targetSize);
final Offset targetOffset;
final Size targetSize;
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = Colors.black.withOpacity(0.9)
..style = PaintingStyle.fill;
final Rect holeRect = Rect.fromLTWH(
targetOffset.dx - 8,
targetOffset.dy - 8,
targetSize.width + 16,
targetSize.height + 16,
);
final Path backgroundPath = Path()
..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
final Path holePath = Path()
..addRRect(RRect.fromRectAndRadius(holeRect, const Radius.circular(12)))
..close();
// Create the spotlight by subtracting the target area using even-odd fill.
backgroundPath.addPath(holePath, Offset.zero);
backgroundPath.fillType = PathFillType.evenOdd;
canvas.drawPath(backgroundPath, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// this is a message bubble drawer, it is used to position the bubble correctly on screen according to the position of the focused widget
Widget _messageBubble({
required BuildContext context,
required Offset targetOffset,
required Size targetSize,
required Widget child,
required Color targetObserverColor,
}) {
final double screenHeight = MediaQuery.of(context).size.height;
final double screenWidth = MediaQuery.of(context).size.width;
const double padding = 15.0;
const double tooltipWidth = 250.0;
final bool isNearTop = targetOffset.dy < screenHeight * 0.25;
final bool isNearBottom = targetOffset.dy > screenHeight * 0.75;
bool showBelow;
if (isNearTop) {
showBelow = true;
} else if (isNearBottom) {
showBelow = false;
} else {
// Middle zone – decide based on space available
final double spaceAbove = targetOffset.dy;
final double spaceBelow =
screenHeight - (targetOffset.dy + targetSize.height);
showBelow = spaceBelow > spaceAbove;
}
double top = showBelow
? targetOffset.dy + targetSize.height + padding
: targetOffset.dy - _bubbleWidth - padding;
// Prevent tooltip from going off-screen horizontally
double left = targetOffset.dx;
if (left + tooltipWidth > screenWidth) {
left = screenWidth - tooltipWidth - (padding * 3);
} else if (left < padding) {
left = padding;
}
return Positioned(
top: top,
left: left,
child: CustomPaint(
size: const Size(_bubbleWidth, _bubbleHeight),
painter: _SpeechBubblePainter(targetObserverColor),
child: child,
),
);
}
/// this is a speech bubble class for smoothness,
class _SpeechBubblePainter extends CustomPainter {
_SpeechBubblePainter(this.targetObserverColor);
final Color targetObserverColor;
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = targetObserverColor
..strokeWidth = 4
..style = PaintingStyle.stroke;
final Path path = Path();
// Start top-left and move clockwise
path.moveTo(20, 0);
path.quadraticBezierTo(0, 0, 0, 20); // Top-left curve
path.lineTo(0, size.height - 40); // Left side
path.quadraticBezierTo(
0,
size.height,
30,
size.height,
); // Bottom-left curve
// Tail to show like it's a message
path.lineTo(50, size.height);
path.lineTo(20, size.height + 30);
path.lineTo(90, size.height);
// Bottom-right and up
path.quadraticBezierTo(
size.width,
size.height,
size.width,
size.height - 30,
);
path.lineTo(size.width, 20); // Right side
path.quadraticBezierTo(
size.width,
0,
size.width - 20,
0,
); // Top-right curve
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}