Files
plezy/lib/focus/key_event_utils.dart
T

220 lines
7.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Handles back key events by popping the current route.
///
/// Optionally pass a [result] to return to the previous route.
///
/// Use this as an `onKeyEvent` callback for Focus widgets that need
/// simple back navigation behavior:
///
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(context, event),
/// child: ...
/// )
/// ```
///
/// With a result value:
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(
/// context,
/// event,
/// result: _hasChanges,
/// ),
/// child: ...
/// )
/// ```
class BackKeyCoordinator {
static bool _handledThisFrame = false;
static bool _clearScheduled = false;
static int _clearGeneration = 0;
static void markHandled() {
_handledThisFrame = true;
if (_clearScheduled) return;
_clearScheduled = true;
final clearGeneration = _clearGeneration;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (clearGeneration != _clearGeneration) return;
_handledThisFrame = false;
_clearScheduled = false;
});
// addPostFrameCallback does not request a frame. Ensure the one-shot
// marker cannot leak when handling Back does not otherwise schedule one.
WidgetsBinding.instance.scheduleFrame();
}
static void clear() {
_handledThisFrame = false;
_clearScheduled = false;
// Invalidate a pending callback so it cannot clear a newer marker.
_clearGeneration++;
}
static bool consumeIfHandled() {
if (!_handledThisFrame) return false;
clear();
return true;
}
}
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Check if this BACK event should be suppressed (e.g., after modal closed)
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
// a non-keyboard device, but the same down-only handling is still required.
// The suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here
// because onBack typically calls Navigator.pop, swapping the focus tree
// before the matching KeyUp is dispatched — the orphaned KeyUp then never
// reaches a consumeIfSuppressed call, pinning the suppressor armed and
// silently swallowing the next press's KeyDown.
if (PlatformDetector.isAppleTV()) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
}
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
onBack();
return KeyEventResult.handled;
}
if (event is KeyDownEvent || event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
// Let system handle back if there's nothing to pop (e.g. root screen)
if (!Navigator.canPop(context)) {
return KeyEventResult.ignored;
}
// Don't handle back when a dialog/overlay is on top of our route —
// the overlay handles its own dismissal via DismissAction.
if (ModalRoute.of(context)?.isCurrent != true) {
return KeyEventResult.ignored;
}
// Handle on KeyUpEvent to prevent double-pop when returning from child screens
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
/// Consumes all select-key events (down, repeat, up) so they don't reach
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored;
if (event is KeyDownEvent) onActivate();
return KeyEventResult.handled;
}
/// Creates a [FocusOnKeyEventCallback] that dispatches d-pad / arrow keys to
/// the provided directional callbacks.
///
/// Each callback is optional. Directions without a callback are ignored
/// (passed through to the framework). Directions mapped to a callback
/// automatically return [KeyEventResult.handled].
///
/// When [trapHorizontalEdges] is true, LEFT/RIGHT with no callback return
/// [KeyEventResult.handled] (consumed) instead of being passed through. Use
/// this for a self-contained horizontal group (e.g. a button row) so D-pad
/// can't escape off the edge into an off-screen "black hole" (#1181); wire an
/// explicit [onLeft]/[onRight] only where edge-escape into another region is
/// intended. UP/DOWN are unaffected and always pass through when unmapped.
///
/// Directional keys repeat on [KeyRepeatEvent] (via [isActionable]).
/// Select is one-shot: fires on [KeyDownEvent] only, consumes repeat and up.
///
/// ```dart
/// Focus(
/// onKeyEvent: dpadKeyHandler(
/// onUp: () => _focusAppBar(),
/// onDown: () => _focusContent(),
/// onLeft: () => _navigateToSidebar(),
/// onSelect: () => _play(),
/// ),
/// child: ...
/// )
/// ```
FocusOnKeyEventCallback dpadKeyHandler({
VoidCallback? onUp,
VoidCallback? onDown,
VoidCallback? onLeft,
VoidCallback? onRight,
VoidCallback? onSelect,
bool trapHorizontalEdges = false,
}) {
return (FocusNode _, KeyEvent event) {
// Select: one-shot activation (no repeat), must run before isActionable
// filter so KeyUpEvent is also consumed.
if (onSelect != null) {
final result = handleOneShotSelect(event, onSelect);
if (result != KeyEventResult.ignored) return result;
}
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isUpKey && onUp != null) {
onUp();
return KeyEventResult.handled;
}
if (key.isDownKey && onDown != null) {
onDown();
return KeyEventResult.handled;
}
if (key.isLeftKey) {
if (onLeft != null) {
onLeft();
return KeyEventResult.handled;
}
if (trapHorizontalEdges) return KeyEventResult.handled;
}
if (key.isRightKey) {
if (onRight != null) {
onRight();
return KeyEventResult.handled;
}
if (trapHorizontalEdges) return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
}
/// Navigator observer that automatically suppresses stray back KeyUp events
/// after any route pop caused by a back key press.
///
/// This catches pops triggered by Flutter's built-in DismissAction (which fires
/// on KeyDown for dialogs) and Android TV system back gestures, preventing the
/// orphaned KeyUp from propagating to the underlying screen's back handler.
class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
// so the suppressor isn't needed and arming it would pin state across the
// pop's focus-tree swap. (The atomic engine fix delivers KeyDown+KeyUp in
// a single recognizer Began callback, so didPop fires squarely inside the
// window where BackKeyPressTracker.isBackKeyDown is true.)
if (PlatformDetector.isAppleTV()) return;
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
}
}