Files
plezy/lib/screens/libraries/alpha_scroll_handle.dart

260 lines
9.1 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import '../../i18n/strings.g.dart';
import '../../media/library_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<LibraryFirstCharacter> firstCharacters;
final void Function(int targetIndex) onJump;
final bool descending;
/// 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,
this.descending = false,
required this.isScrolling,
});
@override
State<AlphaScrollHandle> createState() => _AlphaScrollHandleState();
}
class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTickerProviderStateMixin {
late AlphaJumpHelper _helper;
late AnimationController _opacityController;
Timer? _hideTimer;
bool _isDragging = false;
String? _dragLetter;
/// Accumulated fraction (0.01.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, descending: widget.descending);
_opacityController = AnimationController(vsync: this, duration: _showDuration, reverseDuration: _hideDuration);
}
@override
void didUpdateWidget(AlphaScrollHandle oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.firstCharacters != widget.firstCharacters || oldWidget.descending != widget.descending) {
_helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending);
}
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);
if (letter == _dragLetter) return;
setState(() => _dragLetter = letter);
widget.onJump(_helper.indexForLetter(letter) ?? 0);
}
void _onDragEnd(DragEndDetails _) {
setState(() {
_isDragging = false;
_dragLetter = null;
_dragFraction = null;
});
_scheduleHide();
}
int get _semanticLetterIndex {
if (_helper.letters.isEmpty) return -1;
final index = _helper.letters.indexOf(_dragLetter ?? widget.currentLetter);
return index < 0 ? 0 : index;
}
void _stepSemantics(int delta) {
final currentIndex = _semanticLetterIndex;
if (currentIndex < 0) return;
final targetIndex = (currentIndex + delta).clamp(0, _helper.letters.length - 1);
if (targetIndex == currentIndex) return;
final letter = _helper.letters[targetIndex];
_show();
_scheduleHide();
widget.onJump(_helper.indexForLetter(letter) ?? 0);
}
@override
Widget build(BuildContext context) {
final semanticIndex = _semanticLetterIndex;
return Semantics(
label: t.accessibility.alphabetNavigation,
value: _dragLetter ?? widget.currentLetter,
hint: t.accessibility.alphabetScrollHint,
increasedValue: semanticIndex >= 0 && semanticIndex < _helper.letters.length - 1
? _helper.letters[semanticIndex + 1]
: null,
decreasedValue: semanticIndex > 0 ? _helper.letters[semanticIndex - 1] : null,
onIncrease: semanticIndex >= 0 && semanticIndex < _helper.letters.length - 1 ? () => _stepSemantics(1) : null,
onDecrease: semanticIndex > 0 ? () => _stepSemantics(-1) : null,
child: AnimatedBuilder(
animation: _opacityController,
builder: (context, child) {
final opacity = _opacityController.value;
return IgnorePointer(
ignoring: opacity == 0.0,
child: 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: MouseRegion(
cursor: SystemMouseCursors.resizeUpDown,
child: GestureDetector(
excludeFromSemantics: true,
behavior: HitTestBehavior.opaque,
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: SizedBox(
width: _touchTargetWidth,
height: _handleHeight + _touchTargetVerticalPadding * 2,
child: Align(
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: .center,
child: Text(
_dragLetter!,
style: TextStyle(color: colorScheme.onPrimary, fontSize: _bubbleFontSize, fontWeight: .bold),
),
),
),
],
),
);
},
),
),
);
}
}