fix: dpad long-press

This commit is contained in:
edde746
2026-01-23 20:24:20 +01:00
parent 3a2f2c9c69
commit efe9b86707
6 changed files with 182 additions and 56 deletions
+20
View File
@@ -57,3 +57,23 @@ extension DpadKeyExtension on LogicalKeyboardKey {
/// Whether this key moves focus down.
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
/// Global helper to suppress the next SELECT key-up event.
class SelectKeyUpSuppressor {
static bool _suppressSelectUntilKeyUp = false;
static void suppressSelectUntilKeyUp() {
_suppressSelectUntilKeyUp = true;
}
static bool consumeIfSuppressed(KeyEvent event) {
if (!_suppressSelectUntilKeyUp) return false;
if (event.logicalKey.isSelectKey) {
if (event is KeyUpEvent) {
_suppressSelectUntilKeyUp = false;
}
return true;
}
return false;
}
}
+10
View File
@@ -182,6 +182,12 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
_isFocused = hasFocus;
});
// Reset long press state when focus is lost
if (!hasFocus) {
_longPressTimer?.cancel();
_isSelectKeyDown = false;
}
// Animate scale
if (hasFocus) {
_animationController.forward();
@@ -253,6 +259,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// Call custom key handler first
if (widget.onKeyEvent != null) {
final result = widget.onKeyEvent!(node, event);
+12 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/dpad_navigator.dart';
/// A wrapper widget that provides autofocus functionality for bottom sheets.
///
@@ -56,6 +57,16 @@ class _FocusableBottomSheetState extends State<FocusableBottomSheet> {
@override
Widget build(BuildContext context) {
return widget.child;
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: widget.child,
);
}
}
+47 -14
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
/// A ListTile that accepts a FocusNode for keyboard/controller navigation.
///
/// Uses Flutter's native ListTile focus support - no custom styling wrapper.
/// The focusNode allows programmatic focus control (e.g., auto-focus first item).
class FocusableListTile extends StatelessWidget {
class FocusableListTile extends StatefulWidget {
/// The primary content of the list tile.
final Widget? title;
@@ -41,6 +43,9 @@ class FocusableListTile extends StatelessWidget {
/// The tile's internal padding.
final EdgeInsetsGeometry? contentPadding;
/// If true, consumes the first select key event to avoid accidental activation.
final bool suppressInitialSelect;
const FocusableListTile({
super.key,
this.title,
@@ -55,23 +60,51 @@ class FocusableListTile extends StatelessWidget {
this.focusNode,
this.autofocus = false,
this.contentPadding,
this.suppressInitialSelect = false,
});
@override
State<FocusableListTile> createState() => _FocusableListTileState();
}
class _FocusableListTileState extends State<FocusableListTile> {
bool _suppressionConsumed = false;
@override
Widget build(BuildContext context) {
return ListTile(
title: title,
subtitle: subtitle,
leading: leading,
trailing: trailing,
onTap: onTap,
onLongPress: onLongPress,
dense: dense,
enabled: enabled,
selected: selected,
contentPadding: contentPadding,
focusNode: focusNode,
autofocus: autofocus,
final tile = ListTile(
title: widget.title,
subtitle: widget.subtitle,
leading: widget.leading,
trailing: widget.trailing,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
dense: widget.dense,
enabled: widget.enabled,
selected: widget.selected,
contentPadding: widget.contentPadding,
focusNode: widget.suppressInitialSelect ? null : widget.focusNode,
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
);
if (!widget.suppressInitialSelect) {
return tile;
}
return Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
onKeyEvent: (node, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
if (!_suppressionConsumed && event.logicalKey.isSelectKey) {
_suppressionConsumed = true;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: tile,
);
}
}
+48 -7
View File
@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
@@ -61,6 +64,8 @@ class HubSection extends StatefulWidget {
}
class HubSectionState extends State<HubSection> {
static const _longPressDuration = Duration(milliseconds: 500);
late FocusNode _hubFocusNode;
final ScrollController _scrollController = ScrollController();
@@ -71,6 +76,10 @@ class HubSectionState extends State<HubSection> {
double _itemExtent = 0;
static const double _leadingPadding = 12.0;
Timer? _longPressTimer;
bool _isSelectKeyDown = false;
bool _longPressTriggered = false;
@override
void initState() {
super.initState();
@@ -92,6 +101,7 @@ class HubSectionState extends State<HubSection> {
@override
void dispose() {
_longPressTimer?.cancel();
_hubFocusNode.removeListener(_onFocusChange);
_hubFocusNode.dispose();
_scrollController.dispose();
@@ -99,6 +109,12 @@ class HubSectionState extends State<HubSection> {
}
void _onFocusChange() {
// Reset long press state when focus is lost
if (!_hubFocusNode.hasFocus) {
_longPressTimer?.cancel();
_isSelectKeyDown = false;
_longPressTriggered = false;
}
// Rebuild to update visual focus state
if (mounted) setState(() {});
}
@@ -161,12 +177,43 @@ class HubSectionState extends State<HubSection> {
/// Handle ALL key events at the hub level
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
if (key.isSelectKey) {
if (event is KeyDownEvent) {
if (!_isSelectKeyDown) {
_isSelectKeyDown = true;
_longPressTriggered = false;
_longPressTimer?.cancel();
_longPressTimer = Timer(_longPressDuration, () {
if (!mounted) return;
if (_isSelectKeyDown) {
_longPressTriggered = true;
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
_showContextMenuForCurrentItem();
}
});
}
return KeyEventResult.handled;
} else if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
} else if (event is KeyUpEvent) {
final timerWasActive = _longPressTimer?.isActive ?? false;
_longPressTimer?.cancel();
if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) {
_activateCurrentItem();
}
_isSelectKeyDown = false;
_longPressTriggered = false;
return KeyEventResult.handled;
}
}
// Handle key down and repeat events
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
final itemCount = widget.hub.items.length;
if (itemCount == 0) return KeyEventResult.ignored;
@@ -207,12 +254,6 @@ class HubSectionState extends State<HubSection> {
return KeyEventResult.handled;
}
// Select: activate the current item
if (key.isSelectKey) {
_activateCurrentItem();
return KeyEventResult.handled;
}
// Context menu key: show context menu
if (key.isContextMenuKey) {
_showContextMenuForCurrentItem();
+45 -34
View File
@@ -16,6 +16,7 @@ import '../utils/library_refresh_notifier.dart';
import '../utils/snackbar_helper.dart';
import '../utils/dialogs.dart';
import '../utils/focus_utils.dart';
import '../focus/dpad_navigator.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../utils/smart_deletion_handler.dart';
@@ -1222,44 +1223,54 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
top = screenSize.height - estimatedHeight - 8;
}
return Stack(
children: [
// Barrier to close menu when clicking outside
Positioned.fill(
child: GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Stack(
children: [
// Barrier to close menu when clicking outside
Positioned.fill(
child: GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
),
// Menu
Positioned(
left: left,
top: top,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
return FocusableListTile(
focusNode: index == 0 ? _initialFocusNode : null,
leading: AppIcon(action.icon, fill: 1, size: 20),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
);
}).toList(),
// Menu
Positioned(
left: left,
top: top,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
return FocusableListTile(
focusNode: index == 0 ? _initialFocusNode : null,
leading: AppIcon(action.icon, fill: 1, size: 20),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
);
}).toList(),
),
),
),
),
),
],
],
),
);
}
}