fix: dpad long-press
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user