import 'dart:ui' as ui; import 'package:flutter/services.dart'; extension KeyEventActionable on KeyEvent { bool get isActionable => this is KeyDownEvent || this is KeyRepeatEvent; bool get isPhysicalKeyboardEvent => deviceType == ui.KeyEventDeviceType.keyboard; // Only true keyboard submit keys belong here. LogicalKeyboardKey.select is // a TV-remote / dpad-center key (Android DPAD_CENTER, tvOS UIPressTypeSelect) // — USB keyboards never emit it. The custom Flutter tvOS engine reports its // synthesized Siri Remote presses with deviceType=keyboard, so classifying // select-with-keyboard-deviceType as a "keyboard enter" would route center // dpad through TextField submit and skip the TV virtual keyboard. bool get isPhysicalKeyboardEnter => deviceType == ui.KeyEventDeviceType.keyboard && (logicalKey == LogicalKeyboardKey.enter || logicalKey == LogicalKeyboardKey.numpadEnter); bool get isTvSelectEvent { // Dpad-center / gamepad-A are TV-remote-only — always treat as TV select, // regardless of the deviceType claim from the engine. if (logicalKey == LogicalKeyboardKey.select || logicalKey == LogicalKeyboardKey.gameButtonA) return true; if (isPhysicalKeyboardEvent) return false; return logicalKey == LogicalKeyboardKey.enter || logicalKey == LogicalKeyboardKey.numpadEnter; } } final _dpadDirectionKeys = { LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight, }; final _selectKeys = { LogicalKeyboardKey.select, LogicalKeyboardKey.enter, LogicalKeyboardKey.numpadEnter, LogicalKeyboardKey.gameButtonA, }; final _backKeys = { LogicalKeyboardKey.escape, LogicalKeyboardKey.goBack, LogicalKeyboardKey.browserBack, LogicalKeyboardKey.gameButtonB, }; final _contextMenuKeys = {LogicalKeyboardKey.contextMenu, LogicalKeyboardKey.gameButtonX}; extension DpadKeyExtension on LogicalKeyboardKey { bool get isDpadDirection => _dpadDirectionKeys.contains(this); bool get isSelectKey => _selectKeys.contains(this); bool get isBackKey => _backKeys.contains(this); bool get isContextMenuKey => _contextMenuKeys.contains(this); bool get isNavigationKey => isDpadDirection || isSelectKey || isBackKey || isContextMenuKey || this == LogicalKeyboardKey.tab; bool get isLeftKey => this == LogicalKeyboardKey.arrowLeft; bool get isRightKey => this == LogicalKeyboardKey.arrowRight; bool get isUpKey => this == LogicalKeyboardKey.arrowUp; bool get isDownKey => this == LogicalKeyboardKey.arrowDown; } /// Base class for suppressing key-up events after a key category triggers an /// action (e.g. opening a sheet). While suppressed, all events for the matched /// key category are consumed; suppression auto-clears on [KeyUpEvent]. class _KeyUpSuppressor { final bool Function(LogicalKeyboardKey) _keyMatcher; _KeyUpSuppressor(this._keyMatcher); bool _suppressed = false; void suppress() => _suppressed = true; void clearSuppression() => _suppressed = false; /// Returns `true` (consumed) when the event belongs to the matched key /// category and suppression is active. Clears suppression on [KeyUpEvent]. bool consumeIfSuppressed(KeyEvent event) { if (!_suppressed) return false; if (_keyMatcher(event.logicalKey)) { if (event is KeyUpEvent) _suppressed = false; return true; } return false; } } /// Global helper to suppress the next SELECT key-up event. class SelectKeyUpSuppressor { static final _instance = _KeyUpSuppressor((k) => k.isSelectKey); static void suppressSelectUntilKeyUp() => _instance.suppress(); static void clearSuppression() => _instance.clearSuppression(); static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event); } /// Global helper to suppress the next BACK key-up event. /// /// Use this when a modal (bottom sheet, dialog) closes to prevent /// the BACK key-up from propagating to the underlying screen. class BackKeyUpSuppressor { static final _instance = _KeyUpSuppressor((k) => k.isBackKey); static bool _closedViaBackKey = false; /// Mark that a modal is being closed via back key press. /// Call this before Navigator.pop() in back key handlers. static void markClosedViaBackKey() { _closedViaBackKey = true; } /// Request suppression of back key-up events. /// Suppression is skipped if the modal was closed via back key /// (since the key-up already triggered the close). static void suppressBackUntilKeyUp() { if (_closedViaBackKey) { _closedViaBackKey = false; return; } _instance.suppress(); } /// Clear any pending suppression. Call when opening a new modal /// to ensure stale suppression from previous closes doesn't affect it. static void clearSuppression() { _instance.clearSuppression(); _closedViaBackKey = false; } static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event); } /// Tracks whether a back key is currently physically pressed. /// /// Used by [BackKeySuppressorObserver] to detect when a route pop was /// caused by a back key press (e.g. Flutter's built-in DismissAction, /// DismissAction on KeyRepeat, or Android TV system back gesture) so it /// can automatically suppress the stray KeyUp that follows. class BackKeyPressTracker { static bool _isBackKeyDown = false; /// Whether a back key is currently held down. /// /// Also checks [HardwareKeyboard.instance.logicalKeysPressed] as a /// fallback in case our tracking drifted out of sync. static bool get isBackKeyDown { if (_isBackKeyDown) return true; return HardwareKeyboard.instance.logicalKeysPressed.any((key) => key.isBackKey); } static bool handleKeyEvent(KeyEvent event) { if (event.logicalKey.isBackKey) { // KeyDown and KeyRepeat both mean the key is physically held. _isBackKeyDown = event is! KeyUpEvent; } return false; // Never consume } }