import 'dart:async'; import 'package:flutter/material.dart'; import '../../models/plex_first_character.dart'; import 'alpha_jump_helper.dart'; /// Phone-optimized draggable scroll handle that appears on scroll and shows /// a letter bubble when dragged. Designed to match the Plex app's scroll /// indicator behavior. /// /// Desktop/tablet/TV should use [AlphaJumpBar] instead. class AlphaScrollHandle extends StatefulWidget { final List firstCharacters; final void Function(int targetIndex) onJump; /// The letter currently visible at the top of the grid, derived from the /// actual item's sort title by the parent widget. final String currentLetter; /// Whether the parent scroll view is currently scrolling. final bool isScrolling; const AlphaScrollHandle({ super.key, required this.firstCharacters, required this.onJump, required this.currentLetter, required this.isScrolling, }); @override State createState() => _AlphaScrollHandleState(); } class _AlphaScrollHandleState extends State with SingleTickerProviderStateMixin { late AlphaJumpHelper _helper; late AnimationController _opacityController; Timer? _hideTimer; bool _isDragging = false; String? _dragLetter; /// Accumulated fraction (0.0–1.0) during drag, driven by delta movement. double? _dragFraction; /// Cached track height from the last layout pass, used in drag callbacks. double _trackHeight = 0; static const _showDuration = Duration(milliseconds: 200); static const _hideDuration = Duration(milliseconds: 200); static const _autoHideDelay = Duration(seconds: 2); // Handle dimensions static const double _handleWidth = 6.0; static const double _handleHeight = 44.0; static const double _touchTargetWidth = 44.0; static const double _touchTargetVerticalPadding = 20.0; static const double _handleRadius = 3.0; // Bubble dimensions static const double _bubbleSize = 56.0; static const double _bubbleFontSize = 24.0; static const double _bubbleMarginRight = 8.0; @override void initState() { super.initState(); _helper = AlphaJumpHelper(widget.firstCharacters); _opacityController = AnimationController(vsync: this, duration: _showDuration, reverseDuration: _hideDuration); } @override void didUpdateWidget(AlphaScrollHandle oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.firstCharacters != widget.firstCharacters) { _helper = AlphaJumpHelper(widget.firstCharacters); } if (widget.isScrolling && !oldWidget.isScrolling) { _show(); } else if (!widget.isScrolling && oldWidget.isScrolling) { _scheduleHide(); } } @override void dispose() { _hideTimer?.cancel(); _opacityController.dispose(); super.dispose(); } void _show() { _hideTimer?.cancel(); _opacityController.forward(); } void _scheduleHide() { if (_isDragging) return; _hideTimer?.cancel(); _hideTimer = Timer(_autoHideDelay, () { if (mounted && !_isDragging) { _opacityController.reverse(); } }); } void _onDragStart(DragStartDetails _) { setState(() { _isDragging = true; _dragFraction = _helper.fractionForLetter(widget.currentLetter); _dragLetter = widget.currentLetter; }); _hideTimer?.cancel(); _show(); } void _onDragUpdate(DragUpdateDetails details) { final usableHeight = _trackHeight - _handleHeight; if (usableHeight <= 0 || _dragFraction == null) return; final newFraction = (_dragFraction! + details.delta.dy / usableHeight).clamp(0.0, 1.0); _dragFraction = newFraction; final letter = _helper.letterAtFraction(newFraction); setState(() => _dragLetter = letter); widget.onJump(_helper.indexForLetter(letter) ?? 0); } void _onDragEnd(DragEndDetails _) { setState(() { _isDragging = false; _dragLetter = null; _dragFraction = null; }); _scheduleHide(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _opacityController, builder: (context, child) { final opacity = _opacityController.value; // Prevent stealing taps when fully hidden if (opacity == 0.0) return const SizedBox.shrink(); return Opacity(opacity: opacity, child: child); }, child: LayoutBuilder( builder: (context, constraints) { final trackHeight = constraints.maxHeight; _trackHeight = trackHeight; final fraction = _isDragging && _dragFraction != null ? _dragFraction! : _helper.fractionForLetter(widget.currentLetter); final usableHeight = trackHeight - _handleHeight; final handleTop = usableHeight > 0 ? (fraction * usableHeight) : 0.0; final colorScheme = Theme.of(context).colorScheme; return SizedBox( width: _touchTargetWidth + _bubbleSize + _bubbleMarginRight, height: trackHeight, child: Stack( clipBehavior: Clip.none, children: [ // Handle pill with touch target — only around the pill's position, // not the full track, so it doesn't steal scroll gestures. // Extra vertical padding makes it easier to grab. Positioned( right: 0, top: handleTop - _touchTargetVerticalPadding, child: GestureDetector( behavior: HitTestBehavior.opaque, onVerticalDragStart: _onDragStart, onVerticalDragUpdate: _onDragUpdate, onVerticalDragEnd: _onDragEnd, child: SizedBox( width: _touchTargetWidth, height: _handleHeight + _touchTargetVerticalPadding * 2, child: Align( alignment: Alignment.centerRight, child: Container( margin: const EdgeInsets.only(right: 2), width: _handleWidth, height: _handleHeight, decoration: BoxDecoration( color: colorScheme.onSurface.withValues(alpha: 0.5), borderRadius: const BorderRadius.all(Radius.circular(_handleRadius)), ), ), ), ), ), ), // Letter bubble (only while dragging) if (_isDragging && _dragLetter != null) Positioned( right: _touchTargetWidth + _bubbleMarginRight, top: handleTop + (_handleHeight - _bubbleSize) / 2, child: Container( width: _bubbleSize, height: _bubbleSize, decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), alignment: Alignment.center, child: Text( _dragLetter!, style: TextStyle( color: colorScheme.onPrimary, fontSize: _bubbleFontSize, fontWeight: FontWeight.bold, ), ), ), ), ], ), ); }, ), ); } }