Files
plezy/lib/widgets/alpha_jump_bar.dart
T

221 lines
7.4 KiB
Dart
Raw 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 'package:flutter/services.dart';
import '../models/plex_first_character.dart';
import 'alpha_jump_helper.dart';
/// Vertical strip of letters (#, AZ) for jumping through sorted library items.
///
/// Pre-computes a cumulative index map from [firstCharacters] data so that
/// tapping a letter triggers [onJump] with the item index where that letter
/// begins. Supports both touch (tap/drag) and D-pad (up/down/select) input.
class AlphaJumpBar extends StatefulWidget {
final List<PlexFirstCharacter> firstCharacters;
final void Function(int targetIndex) onJump;
final int currentFirstVisibleIndex;
final FocusNode? focusNode;
final VoidCallback? onNavigateLeft;
final VoidCallback? onBack;
const AlphaJumpBar({
super.key,
required this.firstCharacters,
required this.onJump,
required this.currentFirstVisibleIndex,
this.focusNode,
this.onNavigateLeft,
this.onBack,
});
@override
State<AlphaJumpBar> createState() => _AlphaJumpBarState();
}
class _AlphaJumpBarState extends State<AlphaJumpBar> {
late AlphaJumpHelper _helper;
/// Currently highlighted letter index (for D-pad navigation).
int _highlightedIndex = 0;
/// Whether this bar currently has focus (for D-pad mode).
bool _hasFocus = false;
/// Debounce timer for keyboard-driven jumps.
Timer? _debounce;
@override
void initState() {
super.initState();
_helper = AlphaJumpHelper(widget.firstCharacters);
}
@override
void didUpdateWidget(AlphaJumpBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.firstCharacters != widget.firstCharacters) {
_helper = AlphaJumpHelper(widget.firstCharacters);
}
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
void _jumpToLetter(String letter) {
final index = _helper.indexForLetter(letter);
if (index != null) {
widget.onJump(index);
}
}
/// Schedule a debounced jump to the currently highlighted letter.
/// The highlight updates immediately for visual feedback, but the
/// actual scroll only fires after keyboard input settles.
void _debouncedJump() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 150), () {
_jumpToLetter(AlphaJumpHelper.allLetters[_highlightedIndex]);
});
}
/// Resolves a vertical drag position to a letter index.
int _letterIndexFromDy(double dy, double totalHeight) {
final index = (dy / totalHeight * AlphaJumpHelper.allLetters.length).floor();
return index.clamp(0, AlphaJumpHelper.allLetters.length - 1);
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (_highlightedIndex > 0) {
setState(() => _highlightedIndex--);
_debouncedJump();
}
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (_highlightedIndex < AlphaJumpHelper.allLetters.length - 1) {
setState(() => _highlightedIndex++);
_debouncedJump();
}
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
widget.onNavigateLeft?.call();
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.escape ||
event.logicalKey == LogicalKeyboardKey.goBack ||
event.logicalKey == LogicalKeyboardKey.gameButtonB) {
widget.onBack?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final currentLetter = _helper.currentLetter(widget.currentFirstVisibleIndex);
final colorScheme = Theme.of(context).colorScheme;
return Focus(
focusNode: widget.focusNode,
onKeyEvent: _handleKeyEvent,
onFocusChange: (hasFocus) {
setState(() {
_hasFocus = hasFocus;
if (hasFocus) {
// Start highlight at the current letter when gaining focus
final idx = AlphaJumpHelper.allLetters.indexOf(currentLetter);
if (idx >= 0) _highlightedIndex = idx;
}
});
},
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) {
final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight);
setState(() => _highlightedIndex = idx);
_jumpToLetter(AlphaJumpHelper.allLetters[idx]);
},
onVerticalDragUpdate: (details) {
final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight);
if (idx != _highlightedIndex) {
setState(() => _highlightedIndex = idx);
_jumpToLetter(AlphaJumpHelper.allLetters[idx]);
}
},
child: Container(
width: 28,
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.7),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(AlphaJumpHelper.allLetters.length, (i) {
final letter = AlphaJumpHelper.allLetters[i];
final isActive = _helper.activeLetters.contains(letter);
final isCurrent = letter == currentLetter && !_hasFocus;
final isHighlighted = _hasFocus && i == _highlightedIndex;
BoxDecoration? decoration;
if (isHighlighted) {
decoration = BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle);
} else if (isCurrent) {
decoration = BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.3),
shape: BoxShape.circle,
);
}
Color letterColor;
if (isHighlighted) {
letterColor = colorScheme.onPrimary;
} else if (isCurrent) {
letterColor = colorScheme.primary;
} else if (isActive) {
letterColor = colorScheme.onSurface;
} else {
letterColor = colorScheme.onSurface.withValues(alpha: 0.25);
}
return SizedBox(
height: constraints.maxHeight / AlphaJumpHelper.allLetters.length,
child: Center(
child: Container(
width: 22,
height: 22,
decoration: decoration,
alignment: Alignment.center,
child: Text(
letter,
style: TextStyle(
fontSize: 10,
fontWeight: (isCurrent || isHighlighted) ? FontWeight.bold : FontWeight.normal,
color: letterColor,
),
),
),
),
);
}),
),
),
);
},
),
);
}
}