Preview:
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';

import '../../../../app_core/utils/app_style.dart';

class OverlayAction {
  final String title;
  final Function() onTap;

  OverlayAction({required this.title, required this.onTap});
}

class OverlayPortalWidget extends StatefulWidget {
  final Widget child;
  final double offsetY;
  final List<OverlayAction> actions;

  const OverlayPortalWidget({
    super.key,
    required this.child,
    this.offsetY = 8.0,
    required this.actions,
  });

  @override
  State<OverlayPortalWidget> createState() => _OverlayPortalWidgetState();
}

class _OverlayPortalWidgetState extends State<OverlayPortalWidget>
    with SingleTickerProviderStateMixin {
  final OverlayPortalController _tooltipController = OverlayPortalController();
  late AnimationController _animationController;
  late Animation<double> _fadeAnimation;
  final GlobalKey _childKey = GlobalKey();
  final GlobalKey _tooltipKey = GlobalKey();

  Offset _overlayPosition = Offset.zero;
  bool _showAbove = false;
  late VoidCallback _routerListener;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
      reverseDuration: const Duration(milliseconds: 150),
    );
    _fadeAnimation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeOut,
      reverseCurve: Curves.easeIn,
    );

    // Добавляем слушатель маршрута в GoRouter
    _routerListener = () {
      if (_tooltipController.isShowing) {
        _tooltipController.hide();
      }
    };
    GoRouter.of(context).routerDelegate.addListener(_routerListener);
  }

  @override
  void dispose() {
    _tooltipController.hide(); // Гарантированное закрытие тултипа
    _animationController.dispose();
    GoRouter.of(context).routerDelegate.removeListener(_routerListener); // Удаление слушателя
    super.dispose();
  }

  void _toggleOverlay() {
    if (_tooltipController.isShowing) {
      _animationController.reverse().then((_) => _tooltipController.hide());
    } else {
      _calculateOverlayPosition();
      _tooltipController.show();
      _animationController.forward();
    }
  }

  /// Вычисляем позицию тултипа, чтобы он не выходил за границы экрана
  void _calculateOverlayPosition() {
    final RenderBox renderBox =
    _childKey.currentContext?.findRenderObject() as RenderBox;
    final Offset localOffset = renderBox.localToGlobal(Offset.zero);
    final Size screenSize = MediaQuery.of(context).size;

    WidgetsBinding.instance.addPostFrameCallback((_) {
      final RenderBox? tooltipBox =
      _tooltipKey.currentContext?.findRenderObject() as RenderBox?;
      final double tooltipHeight = tooltipBox?.size.height ?? 50.h;
      final double tooltipWidth = tooltipBox?.size.width ?? 200.w;

      double newX = localOffset.dx;
      double newY = localOffset.dy + renderBox.size.height + widget.offsetY;

      // Проверка на выход за правый край экрана
      if (newX + tooltipWidth > screenSize.width) {
        newX = screenSize.width - tooltipWidth - 30;
      }

      // Проверка на выход за левый край экрана
      if (newX < 0) {
        newX = 8; // Минимальный отступ от экрана
      }

      // Если тултип выходит за нижний край — показываем сверху
      bool showAbove = false;
      if (newY + tooltipHeight > screenSize.height) {
        newY = localOffset.dy - tooltipHeight - widget.offsetY;
        showAbove = true;
      }

      setState(() {
        _overlayPosition = Offset(newX, newY);
        _showAbove = showAbove;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _toggleOverlay,
      child: OverlayPortal(
        controller: _tooltipController,
        overlayChildBuilder: (context) {
          return Positioned(
            left: _overlayPosition.dx,
            top: _overlayPosition.dy,
            child: FadeTransition(
              opacity: _fadeAnimation,
              child: Material(
                key: _tooltipKey,
                color: Colors.transparent,
                child: _listActions(),
              ),
            ),
          );
        },
        child: Container(
          key: _childKey,
          child: AbsorbPointer(child: widget.child),
        ),
      ),
    );
  }

  Widget _listActions() {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12.r),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 5,
            spreadRadius: 2,
          ),
        ],
      ),
      clipBehavior: Clip.antiAlias,
      child: Column(
        children: widget.actions.map(_action).toList(),
      ),
    );
  }

  Widget _action(OverlayAction action) {
    return GestureDetector(
      onTap: () {
        _tooltipController.hide();
        action.onTap.call();
      },
      child: Container(
        width: 150.w,
        color: Colors.white,
        padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
        child: Row(
          children: [
            FittedBox(
              child: Text(
                action.title.tr(),
                maxLines: 1,
                textAlign: TextAlign.start,
                style: Theme.of(context).textTheme.titleSmall?.copyWith(
                  color: AppStyle.dark,
                  fontSize: 12.sp,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
downloadDownload PNG downloadDownload JPEG downloadDownload SVG

Tip: You can change the style, width & colours of the snippet with the inspect tool before clicking Download!

Click to optimize width for Twitter