145 lines
4.8 KiB
Dart
145 lines
4.8 KiB
Dart
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;
|
|
}
|
|
|
|
@override
|
|
State<InputModeTracker> createState() => _InputModeTrackerState();
|
|
}
|
|
|
|
class _InputModeTrackerState extends State<InputModeTracker> {
|
|
// 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;
|
|
}
|
|
}
|