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); /// Whether this key is a shell / remote control key rather than a text /// character — D-pad direction, select, back, context menu, or Tab. /// /// Use it to decide "is this a printable character?" and "must this route /// consume the key so it cannot leak to the route below?". It is NOT evidence /// that the viewer wants to navigate by focus — `eventRequestsFocusNavigation` /// in focus_navigation_intent.dart answers that, and conflating the two is what /// made a plain Enter switch the whole app into keyboard mode. bool get isReservedControlKey => 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. /// /// Armed when a modal (dialog, sheet) closes while a back key is still held — /// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — so the /// in-flight key-up doesn't propagate to the underlying screen's back handler. class BackKeyUpSuppressor { static final _instance = _KeyUpSuppressor((k) => k.isBackKey); static void suppressBackUntilKeyUp() => _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(); 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 } }