For Help or Contacting me: Home Page

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;
}