import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../utils/platform_detector.dart'; import '../services/gamepad_service.dart'; import 'dpad_navigator.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; /// Tracks whether the user is navigating via keyboard/d-pad or pointer (mouse/touch). /// /// Focus effects should only be shown during keyboard navigation. enum InputMode { keyboard, pointer } /// Provides input mode tracking to descendant widgets. /// /// Wrap your app with this widget to enable input mode detection: /// ```dart /// InputModeTracker( /// child: MaterialApp(...), /// ) /// ``` /// /// Then check the mode in focusable widgets: /// ```dart /// final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); /// ``` class InputModeTracker extends StatefulWidget { final Widget child; const InputModeTracker({super.key, required this.child}); /// Get the current input mode. static InputMode of(BuildContext context) { final provider = context.dependOnInheritedWidgetOfExactType<_InputModeProvider>(); return provider?.mode ?? InputMode.pointer; } /// Convenience method to check if we're in keyboard mode. static bool isKeyboardMode(BuildContext context) { return of(context) == InputMode.keyboard; } /// Whether system back must be blocked because the dpad key handler owns /// back navigation: on Android in keyboard mode (TV/gamepad), letting the /// system back through as well double-pops the route. static bool shouldBlockSystemBack(BuildContext context) { return Platform.isAndroid && isKeyboardMode(context); } @override State createState() => _InputModeTrackerState(); } class _InputModeTrackerState extends State { // Default to keyboard mode on Android TV, pointer mode elsewhere InputMode _mode = TvDetectionService.isTVSync() ? InputMode.keyboard : InputMode.pointer; @override void initState() { super.initState(); // Initialize focus highlight strategy based on starting mode _updateFocusHighlightStrategy(_mode); // Listen to hardware keyboard events globally HardwareKeyboard.instance.addHandler(_handleKeyEvent); // Register callback for gamepad input to switch to keyboard mode GamepadService.onGamepadInput = () => _setMode(InputMode.keyboard); // Register callback for companion remote input to switch to keyboard mode CompanionRemoteReceiver.onRemoteInput = () => _setMode(InputMode.keyboard); } @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleKeyEvent); GamepadService.onGamepadInput = null; CompanionRemoteReceiver.onRemoteInput = null; super.dispose(); } bool _handleKeyEvent(KeyEvent event) { // Track back key press state for automatic suppression of stray KeyUp // events after route pops (see BackKeySuppressorObserver). BackKeyPressTracker.handleKeyEvent(event); // Only switch to keyboard mode on navigation key down (not repeats, releases, // or non-navigation keys like volume buttons or letter keys while typing) if (event is KeyDownEvent && event.logicalKey.isNavigationKey) { _setMode(InputMode.keyboard); } // Return false to let the event continue propagating return false; } void _setMode(InputMode mode) { if (_mode != mode) { setState(() => _mode = mode); } _updateFocusHighlightStrategy(mode); } // Keep Material focus highlights in sync with our input mode so keyboard/gamepad // navigation immediately shows focus without waiting for a real keypress. void _updateFocusHighlightStrategy(InputMode mode) { final desiredStrategy = mode == InputMode.keyboard ? FocusHighlightStrategy.alwaysTraditional : FocusHighlightStrategy.automatic; if (FocusManager.instance.highlightStrategy != desiredStrategy) { FocusManager.instance.highlightStrategy = desiredStrategy; } } @override Widget build(BuildContext context) { // On Android TV, don't switch to pointer mode from pointer events // as D-pad can generate synthetic pointer events that would incorrectly // trigger pointer mode and show a cursor instead of D-pad focus navigation if (TvDetectionService.isTVSync()) { return _InputModeProvider(mode: _mode, child: widget.child); } return Listener( // Switch to pointer mode on mouse activity onPointerDown: (_) => _setMode(InputMode.pointer), onPointerHover: (_) => _setMode(InputMode.pointer), behavior: HitTestBehavior.translucent, child: MouseRegion( cursor: _mode == InputMode.keyboard ? SystemMouseCursors.none : MouseCursor.defer, child: IgnorePointer( ignoring: _mode == InputMode.keyboard, child: _InputModeProvider(mode: _mode, child: widget.child), ), ), ); } } /// InheritedWidget that provides the current input mode. class _InputModeProvider extends InheritedWidget { final InputMode mode; const _InputModeProvider({required this.mode, required super.child}); @override bool updateShouldNotify(_InputModeProvider oldWidget) { return mode != oldWidget.mode; } }