221 lines
7.4 KiB
Dart
221 lines
7.4 KiB
Dart
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 (#, A–Z) 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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|