import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:rimworld_modman/bbcode_converter.dart'; import 'dart:math' as math; /// 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 = 800.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 _isMouseInsideTooltip = false; bool _isPointerListenerActive = false; final ScrollController _scrollController = ScrollController(); @override void dispose() { _removeGlobalListener(); _hideTooltip(); _scrollController.dispose(); 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 } // Calculate appropriate tooltip height based on content length // Longer descriptions need more space final contentLength = widget.markdownContent.length; double dynamicHeight = widget.maxHeight; if (contentLength < 500) { dynamicHeight = 250.0; // Small description } else if (contentLength < 2000) { dynamicHeight = 350.0; // Medium description } else { dynamicHeight = widget.maxHeight; // Large description } // Ensure dynamic height doesn't exceed screen height dynamicHeight = math.min(dynamicHeight, screenSize.height * 0.7); // If tooltip would go below screen bottom, show it above the widget instead bool showAbove = topPosition + dynamicHeight > screenSize.height - 10; if (showAbove) { topPosition = offset.dy - dynamicHeight - 5; // 5px above the widget // If going above would put it offscreen at the top, prefer below but with reduced height if (topPosition < 10) { showAbove = false; topPosition = offset.dy + size.height + 5.0; dynamicHeight = math.min(dynamicHeight, screenSize.height - topPosition - 20); } } // Create follower offset based on calculated position final Offset followerOffset = showAbove ? Offset(size.width * 0.5 - widget.preferredWidth * 0.5, -dynamicHeight - 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: MouseRegion( onEnter: (_) { setState(() { _isMouseInsideTooltip = true; }); }, onExit: (_) { setState(() { _isMouseInsideTooltip = false; // Delay hiding to avoid flickering when moving between tooltip and trigger Future.delayed(const Duration(milliseconds: 100), () { if (!_isMouseInside && !_isMouseInsideTooltip && mounted) { _hideTooltip(); _removeGlobalListener(); } }); }); }, child: CompositedTransformFollower( link: _layerLink, offset: followerOffset, child: Material( elevation: 8.0, borderRadius: BorderRadius.circular(8.0), color: Colors.transparent, child: ConstrainedBox( constraints: BoxConstraints( maxHeight: dynamicHeight, maxWidth: widget.preferredWidth, minHeight: 100.0, // Ensure a minimum height to show content ), child: Container( 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, ), ), Row( mainAxisSize: MainAxisSize.min, children: [ // Scroll to top button InkWell( onTap: () { if (_scrollController.hasClients) { _scrollController.animateTo( 0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }, child: const Padding( padding: EdgeInsets.all(2.0), child: Icon( Icons.arrow_upward, color: Colors.white, size: 16.0, ), ), ), const SizedBox(width: 4.0), // Close button 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: Stack( children: [ // Scrollable content SingleChildScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), scrollDirection: Axis.vertical, child: Padding( padding: const EdgeInsets.all(16.0), child: MarkdownBody( data: widget.markdownContent.length > 7000 ? BBCodeConverter.toMarkdown('${widget.markdownContent.substring(0, 7000)}...\n\n*[Description truncated due to length]*') : BBCodeConverter.toMarkdown(widget.markdownContent), styleSheet: effectiveStyleSheet, shrinkWrap: true, softLineBreak: true, selectable: true, onTapLink: (text, href, title) { if (href != null) { debugPrint('Link tapped: $href'); // Here you could add code to open the link in a browser } }, ), ), ), // Scroll indicator (fades out when scrolling begins) Positioned( bottom: 10, right: 10, child: StreamBuilder( stream: Stream.periodic(const Duration(milliseconds: 200), (_) { if (!_scrollController.hasClients) return 0.0; // Check if content is scrollable final isScrollable = _scrollController.position.maxScrollExtent > 0; // If not scrollable, don't show the indicator if (!isScrollable) return -1.0; return _scrollController.offset; }), builder: (context, snapshot) { // Check for scrollability final isScrollable = snapshot.hasData && snapshot.data! >= 0; // Check for already scrolled state final isScrolled = snapshot.hasData && snapshot.data! > 20.0; // Don't show indicator at all if content is not scrollable if (!isScrollable) return const SizedBox.shrink(); return AnimatedOpacity( opacity: isScrolled ? 0.0 : 0.7, duration: const Duration(milliseconds: 300), child: Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(20), ), child: const Icon( Icons.keyboard_arrow_down, color: Colors.white, size: 16, ), ), ); }, ), ), ], ), ), ], ), ), ), ), ), ), ), ); }, ); } @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: (_) { setState(() { _isMouseInside = true; }); // Delay showing tooltip slightly to avoid accidental triggers Future.delayed(const Duration(milliseconds: 100), () { if (mounted && (_isMouseInside || _isMouseInsideTooltip) && !_isTooltipVisible) { _showTooltip(); } }); }, onExit: (_) { setState(() { _isMouseInside = false; }); // Delay hiding tooltip slightly to allow moving to the tooltip itself Future.delayed(const Duration(milliseconds: 100), () { if (mounted && !_isMouseInside && !_isMouseInsideTooltip && _isTooltipVisible) { _hideTooltip(); _removeGlobalListener(); } }); }, child: widget.child, ), ), ); } }