import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../player_panel_shell.dart'; import 'panel_category_meta.dart'; import 'player_panel_controller.dart'; class PlayerPanelPopover extends StatefulWidget { final PlayerPanelController controller; final Map anchorLinks; final Set downwardCategories; final Widget Function(PlayerPanelCategory) bodyBuilder; final double width; const PlayerPanelPopover({ super.key, required this.controller, required this.anchorLinks, this.downwardCategories = const {}, required this.bodyBuilder, this.width = 340, }); @override State createState() => _PlayerPanelPopoverState(); } class _PlayerPanelPopoverState extends State with SingleTickerProviderStateMixin { late final AnimationController _anim; late final Animation _t; late final Animation _scale; PlayerPanelCategory? _visibleCategory; bool _showing = false; @override void initState() { super.initState(); _anim = AnimationController( vsync: this, duration: const Duration(milliseconds: 180), reverseDuration: const Duration(milliseconds: 130), value: widget.controller.value != null ? 1.0 : 0.0, ); _t = CurvedAnimation( parent: _anim, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); _scale = Tween(begin: 0.96, end: 1.0).animate(_t); _visibleCategory = widget.controller.value; _showing = widget.controller.value != null; _anim.addStatusListener(_onStatus); widget.controller.addListener(_onController); } void _onController() { final c = widget.controller.value; if (c != null) { setState(() { _visibleCategory = c; _showing = true; }); _anim.forward(); } else { _anim.reverse(); } } void _onStatus(AnimationStatus status) { if (status == AnimationStatus.dismissed && mounted && _showing) { setState(() => _showing = false); } } @override void dispose() { widget.controller.removeListener(_onController); _anim.removeStatusListener(_onStatus); _anim.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final category = _visibleCategory; if (!_showing || category == null) return const SizedBox.shrink(); final link = widget.anchorLinks[category]; if (link == null) return const SizedBox.shrink(); final downward = widget.downwardCategories.contains(category); final maxH = (MediaQuery.of(context).size.height * 0.55).clamp(0.0, 360.0); return Stack( children: [ Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.controller.close, ), ), Positioned( left: 0, top: 0, child: CompositedTransformFollower( link: link, targetAnchor: downward ? Alignment.bottomRight : Alignment.topRight, followerAnchor: downward ? Alignment.topRight : Alignment.bottomRight, offset: Offset(0, downward ? 8 : -8), showWhenUnlinked: false, child: FadeTransition( opacity: _t, child: ScaleTransition( scale: _scale, alignment: downward ? Alignment.topRight : Alignment.bottomRight, child: _bubble(category, maxH), ), ), ), ), ], ); } Widget _bubble(PlayerPanelCategory category, double maxHeight) { return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): widget.controller.close, }, child: Focus( autofocus: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () {}, child: ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), child: PlayerPanelShell( icon: panelCategoryIcon(category), title: panelCategoryLabel(category), width: widget.width, onClose: widget.controller.close, child: widget.bodyBuilder(category), ), ), ), ), ); } }