import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; class FootnotePopupOverlay extends StatefulWidget { final Rect anchorRect; final String rawHtml; final VoidCallback onDismiss; final ColorScheme colorScheme; final double zoom; const FootnotePopupOverlay({ super.key, required this.anchorRect, required this.rawHtml, required this.onDismiss, required this.colorScheme, this.zoom = 1.0, }); @override State createState() => FootnotePopupOverlayState(); } class FootnotePopupOverlayState extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; late Animation _slideAnimation; late bool _slideFromLeft; Future playReverseAnimation() async { if (mounted) { await _animationController.reverse(); } } @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); _slideFromLeft = true; if (!_animationController.isAnimating && !_animationController.isCompleted) { _animationController.forward(); } } @override void didChangeDependencies() { super.didChangeDependencies(); final screenWidth = MediaQuery.of(context).size.width; _slideFromLeft = widget.anchorRect.center.dx < (screenWidth / 2); _slideAnimation = Tween( begin: Offset(_slideFromLeft ? -1.0 : 1.0, 0.0), end: Offset.zero, ).animate( CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic, ), ); _animationController.forward(); } @override void dispose() { _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; final safePadding = MediaQuery.of(context).padding; const double minBookmarkWidth = 150; final double maxBookmarkWidth = screenSize.width * 0.8; final spaceBelow = screenSize.height - widget.anchorRect.bottom - max(safePadding.bottom, 32); final spaceAbove = widget.anchorRect.top - safePadding.top; final bool showBelow = spaceBelow >= spaceAbove; final double calculatedMaxHeight = showBelow ? (spaceBelow - safePadding.bottom - 12.0) : (spaceAbove - safePadding.top - 12.0); final double maxBookmarkHeight = calculatedMaxHeight.clamp( 100.0, screenSize.height * 0.4, ); final double topPosition = showBelow ? widget.anchorRect.bottom + 6.0 : -1; final double bottomPosition = !showBelow ? (screenSize.height - widget.anchorRect.top) + 6.0 : -1; final borderRadius = BorderRadius.horizontal( left: _slideFromLeft ? Radius.zero : const Radius.circular(4), right: _slideFromLeft ? const Radius.circular(4) : Radius.zero, ); final isDark = Theme.of(context).brightness == Brightness.dark; // Strip HTML tags for simple text display final plainText = widget.rawHtml .replaceAll(RegExp(r'<[^>]*>'), '') .replaceAll(RegExp(r'\s+'), ' ') .trim(); return Stack( children: [ Positioned.fill( child: GestureDetector( onTap: widget.onDismiss, behavior: HitTestBehavior.opaque, child: Container(color: Colors.transparent), ), ), Positioned( top: topPosition != -1 ? topPosition : null, bottom: bottomPosition != -1 ? bottomPosition : null, left: _slideFromLeft ? 0 : null, right: !_slideFromLeft ? 0 : null, child: Material( color: Colors.transparent, child: SlideTransition( position: _slideAnimation, child: Container( decoration: BoxDecoration( borderRadius: borderRadius, boxShadow: [ BoxShadow( color: widget.colorScheme.shadow.withAlpha( isDark ? 50 : 25, ), blurRadius: 16, offset: Offset(_slideFromLeft ? 4 : -4, 6), ), ], ), child: ClipRRect( borderRadius: borderRadius, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), child: Container( constraints: BoxConstraints( maxHeight: maxBookmarkHeight, maxWidth: maxBookmarkWidth, minWidth: minBookmarkWidth, ), decoration: BoxDecoration( color: widget.colorScheme.surfaceContainerHigh .withValues(alpha: 0.75), border: Border( left: !_slideFromLeft ? BorderSide( color: widget.colorScheme.primary, width: 4, ) : BorderSide.none, right: _slideFromLeft ? BorderSide( color: widget.colorScheme.primary, width: 4, ) : BorderSide.none, top: BorderSide( color: widget.colorScheme.outlineVariant, width: 1, ), bottom: BorderSide( color: widget.colorScheme.outlineVariant, width: 1, ), ), ), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), child: Text( plainText, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: widget.colorScheme.onSurface, height: 1.6, fontSize: (Theme.of(context) .textTheme .bodyMedium ?.fontSize ?? 14.0) * widget.zoom, ), ), ), ), ), ), ), ), ), ), ], ); } }