import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:rimworld_modman/bbcode_converter.dart'; /// A custom tooltip widget that can properly render mod descriptions in BBCode format. class MarkdownTooltip extends StatefulWidget { /// The markdown text to display in the tooltip. final String markdownContent; /// The widget that will trigger the tooltip. final Widget child; /// Optional preferred width for the tooltip. final double preferredWidth; /// Optional maximum height for the tooltip. final double maxHeight; /// Optional text style theme for the markdown. final MarkdownStyleSheet? styleSheet; /// Creates a tooltip that displays markdown content when hovered. const MarkdownTooltip({ super.key, required this.markdownContent, required this.child, this.preferredWidth = 500.0, this.maxHeight = 500.0, this.styleSheet, }); @override State createState() => _MarkdownTooltipState(); } class _MarkdownTooltipState extends State { OverlayEntry? _overlayEntry; final LayerLink _layerLink = LayerLink(); bool _isTooltipVisible = false; bool _isMouseInside = false; bool _isPointerListenerActive = false; @override void dispose() { _removeGlobalListener(); _hideTooltip(); super.dispose(); } void _showTooltip() { if (_isTooltipVisible || !mounted) return; try { _overlayEntry = _createOverlayEntry(); final overlay = Overlay.of(context); if (overlay.mounted) { overlay.insert(_overlayEntry!); _isTooltipVisible = true; _addGlobalListener(); } } catch (e) { debugPrint('Error showing tooltip: $e'); } } void _hideTooltip() { if (_overlayEntry != null) { try { _overlayEntry!.remove(); } catch (e) { debugPrint('Error removing overlay entry: $e'); } _overlayEntry = null; _isTooltipVisible = false; } } void _addGlobalListener() { if (!_isPointerListenerActive && mounted) { _isPointerListenerActive = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { try { GestureBinding.instance.pointerRouter.addGlobalRoute(_handleGlobalPointer); } catch (e) { debugPrint('Error adding global route: $e'); } } }); } } void _removeGlobalListener() { if (_isPointerListenerActive) { try { GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointer); } catch (e) { debugPrint('Error removing global route: $e'); } _isPointerListenerActive = false; } } void _handleGlobalPointer(PointerEvent event) { if (!mounted || _overlayEntry == null) { _removeGlobalListener(); return; } if (event is PointerDownEvent) { // Handle pointer down outside the tooltip final RenderBox? box = context.findRenderObject() as RenderBox?; if (box == null) return; final Offset position = box.localToGlobal(Offset.zero); final Size size = box.size; // Check if tap is inside the trigger widget final bool insideTrigger = event.position.dx >= position.dx && event.position.dx <= position.dx + size.width && event.position.dy >= position.dy && event.position.dy <= position.dy + size.height; if (!insideTrigger) { // Tap outside, hide tooltip WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _hideTooltip(); _removeGlobalListener(); } }); } } } OverlayEntry _createOverlayEntry() { // Calculate render box positions final RenderBox renderBox = context.findRenderObject() as RenderBox; final Size size = renderBox.size; final Offset offset = renderBox.localToGlobal(Offset.zero); // Get screen size final Size screenSize = MediaQuery.of(context).size; // Calculate tooltip position, ensuring it stays on screen double leftPosition = offset.dx - widget.preferredWidth * 0.5 + size.width * 0.5; double topPosition = offset.dy + size.height + 5.0; // 5px below the widget // Adjust horizontal position if too close to screen edges if (leftPosition < 10) { leftPosition = 10; // 10px padding from left edge } else if (leftPosition + widget.preferredWidth > screenSize.width - 10) { leftPosition = screenSize.width - widget.preferredWidth - 10; // 10px padding from right edge } // If tooltip would go below screen bottom, show it above the widget instead bool showAbove = topPosition + widget.maxHeight > screenSize.height - 10; if (showAbove) { topPosition = offset.dy - widget.maxHeight - 5; // 5px above the widget } // Create follower offset based on calculated position final Offset followerOffset = showAbove ? Offset(size.width * 0.5 - widget.preferredWidth * 0.5, -widget.maxHeight - 5) : Offset(size.width * 0.5 - widget.preferredWidth * 0.5, size.height + 5.0); // Create a custom style sheet based on the dark theme final ThemeData theme = Theme.of(context); final MarkdownStyleSheet defaultStyleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith( p: const TextStyle( color: Colors.white, fontSize: 14.0, ), h1: const TextStyle( color: Colors.white, fontSize: 18.0, fontWeight: FontWeight.bold, ), h2: const TextStyle( color: Colors.white, fontSize: 16.0, fontWeight: FontWeight.bold, ), h3: const TextStyle( color: Colors.white, fontSize: 15.0, fontWeight: FontWeight.bold, ), blockquote: const TextStyle( color: Color(0xFFBBBBBB), fontSize: 14.0, fontStyle: FontStyle.italic, ), code: const TextStyle( color: Color(0xFFE0E0E0), fontSize: 13.0, fontFamily: 'monospace', backgroundColor: Color(0xFF505050), ), codeblockDecoration: BoxDecoration( color: const Color(0xFF404040), borderRadius: BorderRadius.circular(4.0), ), a: const TextStyle( color: Color(0xFF8AB4F8), decoration: TextDecoration.underline, ), listBullet: const TextStyle( color: Colors.white, fontSize: 14.0, ), strong: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), em: const TextStyle( color: Colors.white, fontStyle: FontStyle.italic, ), horizontalRuleDecoration: const BoxDecoration( border: Border( top: BorderSide( width: 1.0, color: Color(0xFF606060), ), ), ), ); final effectiveStyleSheet = widget.styleSheet ?? defaultStyleSheet; return OverlayEntry( builder: (context) { return Positioned( left: leftPosition, top: topPosition, width: widget.preferredWidth, child: CompositedTransformFollower( link: _layerLink, offset: followerOffset, child: Material( elevation: 8.0, borderRadius: BorderRadius.circular(8.0), color: Colors.transparent, child: Container( constraints: BoxConstraints( maxHeight: widget.maxHeight, ), decoration: BoxDecoration( color: const Color(0xFF2A3440), // Match app's dark theme card color borderRadius: BorderRadius.circular(8.0), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 5), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(8.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Container( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), width: double.infinity, color: const Color(0xFF3D4A59), // Match app's primary color child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Mod Description', style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14.0, ), ), InkWell( onTap: () { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _hideTooltip(); _removeGlobalListener(); } }); }, child: const Padding( padding: EdgeInsets.all(2.0), child: Icon( Icons.close, color: Colors.white, size: 16.0, ), ), ), ], ), ), // Markdown content Flexible( child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), child: MarkdownBody( data: widget.markdownContent.length > 5000 ? BBCodeConverter.toMarkdown('${widget.markdownContent.substring(0, 5000)}...\n\n*[Description truncated due to length]*') : BBCodeConverter.toMarkdown(widget.markdownContent), styleSheet: effectiveStyleSheet, shrinkWrap: true, softLineBreak: true, selectable: true, ), ), ), ), ], ), ), ), ), ), ); }, ); } @override Widget build(BuildContext context) { return CompositedTransformTarget( link: _layerLink, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { // Toggle tooltip visibility on tap (for mobile users) if (_isTooltipVisible) { _hideTooltip(); _removeGlobalListener(); } else { _showTooltip(); } }, child: MouseRegion( onEnter: (_) { _isMouseInside = true; // Delay showing tooltip slightly to avoid accidental triggers Future.delayed(const Duration(milliseconds: 100), () { if (mounted && _isMouseInside && !_isTooltipVisible) { _showTooltip(); } }); }, onExit: (_) { _isMouseInside = false; // Delay hiding tooltip slightly to allow moving to the tooltip itself Future.delayed(const Duration(milliseconds: 100), () { if (mounted && !_isMouseInside && _isTooltipVisible) { _hideTooltip(); _removeGlobalListener(); } }); }, child: widget.child, ), ), ); } }