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(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; } /// Whether the primary focus currently belongs to an active text editor. /// /// Ancestor key handlers use this to stay off keys a focused field owns. /// Flutter dispatches a key event from the focused node upwards, and the /// editing shortcuts that turn Backspace into a deletion live in /// [DefaultTextEditingShortcuts] at the very top of the app — *above* any /// screen. An ancestor that claims Backspace as "back" therefore both steals /// the navigation and stops the character from ever being deleted (#1741). /// /// [EditableText] builds its [Focus] internally, so the focused node's context /// resolves to the owning [EditableTextState]. bool isTextEditingFocused() { final context = FocusManager.instance.primaryFocus?.context; if (context == null) return false; return context.findAncestorStateOfType() != null; } /// Expands a UTF-16 [range] to whole extended grapheme clusters in [text]. /// /// Flutter selections use UTF-16 code-unit offsets. Custom editors must pass a /// non-empty range containing the code units they intend to replace; the /// returned range is normalized, clamped, and safe to use with /// [String.replaceRange] without splitting a user-perceived character. TextRange expandToGraphemeRange(String text, TextRange range) { if (text.isEmpty) return TextRange.empty; final first = range.start.clamp(0, text.length); final second = range.end.clamp(0, text.length); final start = first <= second ? first : second; final end = first <= second ? second : first; if (start == end) return TextRange.collapsed(start); final boundary = CharacterBoundary(text); return TextRange( start: boundary.getLeadingTextBoundaryAt(start) ?? 0, end: boundary.getTrailingTextBoundaryAt(end - 1) ?? text.length, ); } /// 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(); } } }