Files
plezy/lib/focus/focusable_wrapper.dart
T

266 lines
7.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
/// A wrapper widget that makes its child focusable with D-pad navigation support.
///
/// Provides:
/// - Visual focus indicator (border + scale animation)
/// - Keyboard/D-pad event handling (Enter/Select to activate)
/// - Optional auto-scroll to keep focused item visible
class FocusableWrapper extends StatefulWidget {
/// The child widget to wrap.
final Widget child;
/// Called when the item is selected (Enter/Select/GamepadA).
final VoidCallback? onSelect;
/// Called when long press is triggered (context menu key).
final VoidCallback? onLongPress;
/// Called when focus changes.
final ValueChanged<bool>? onFocusChange;
/// Whether this widget should request focus when first built.
final bool autofocus;
/// Optional external FocusNode for programmatic focus control.
final FocusNode? focusNode;
/// Border radius for the focus indicator.
final double borderRadius;
/// Whether to scroll the widget into view when focused.
final bool autoScroll;
/// Alignment for auto-scroll (0.0 = start, 0.5 = center, 1.0 = end).
final double scrollAlignment;
/// Optional semantic label for accessibility.
final String? semanticLabel;
/// Whether the wrapper can receive focus.
final bool canRequestFocus;
/// Custom key event handler. Return KeyEventResult.handled to consume the event.
/// This is called before the default key handling.
final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent;
const FocusableWrapper({
super.key,
required this.child,
this.onSelect,
this.onLongPress,
this.onFocusChange,
this.autofocus = false,
this.focusNode,
this.borderRadius = FocusTheme.defaultBorderRadius,
this.autoScroll = true,
this.scrollAlignment = 0.5,
this.semanticLabel,
this.canRequestFocus = true,
this.onKeyEvent,
});
@override
State<FocusableWrapper> createState() => _FocusableWrapperState();
}
class _FocusableWrapperState extends State<FocusableWrapper>
with SingleTickerProviderStateMixin {
late FocusNode _focusNode;
bool _ownsNode = false;
bool _isFocused = false;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_initFocusNode();
_initAnimations();
}
void _initFocusNode() {
if (widget.focusNode != null) {
_focusNode = widget.focusNode!;
_ownsNode = false;
} else {
_focusNode = FocusNode(
debugLabel: widget.semanticLabel ?? 'FocusableWrapper',
canRequestFocus: widget.canRequestFocus,
);
_ownsNode = true;
}
}
void _initAnimations() {
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: FocusTheme.focusScale,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
));
}
@override
void didUpdateWidget(FocusableWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
// Handle focusNode changes
if (widget.focusNode != oldWidget.focusNode) {
if (_ownsNode) {
_focusNode.dispose();
}
_initFocusNode();
}
// Update canRequestFocus
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
_focusNode.canRequestFocus = widget.canRequestFocus;
}
}
@override
void dispose() {
_animationController.dispose();
if (_ownsNode) {
_focusNode.dispose();
}
super.dispose();
}
void _handleFocusChange(bool hasFocus) {
if (_isFocused != hasFocus) {
setState(() {
_isFocused = hasFocus;
});
// Animate scale
if (hasFocus) {
_animationController.forward();
} else {
_animationController.reverse();
}
// Auto-scroll into view
if (hasFocus && widget.autoScroll) {
_scrollIntoView();
}
// Notify listener
widget.onFocusChange?.call(hasFocus);
}
}
void _scrollIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final renderObject = context.findRenderObject();
if (renderObject == null) return;
Scrollable.ensureVisible(
context,
alignment: widget.scrollAlignment,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
});
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
// Call custom key handler first
if (widget.onKeyEvent != null) {
final result = widget.onKeyEvent!(node, event);
if (result == KeyEventResult.handled) {
return result;
}
}
// Handle Select/Enter for activation
if (_isSelectKey(event.logicalKey)) {
widget.onSelect?.call();
return KeyEventResult.handled;
}
// Handle context menu key
if (_isContextMenuKey(event.logicalKey)) {
widget.onLongPress?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
bool _isSelectKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter ||
key == LogicalKeyboardKey.gameButtonA;
}
bool _isContextMenuKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.gameButtonX;
}
@override
Widget build(BuildContext context) {
final duration = FocusTheme.getAnimationDuration(context);
// Only show focus effects during keyboard/d-pad navigation
final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context);
// Update animation duration if theme changes
if (_animationController.duration != duration) {
_animationController.duration = duration;
}
Widget result = Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
onFocusChange: _handleFocusChange,
onKeyEvent: _handleKeyEvent,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: showFocus ? _scaleAnimation.value : 1.0,
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
),
child: widget.child,
),
);
},
),
);
// Add semantics if label provided
if (widget.semanticLabel != null) {
result = Semantics(
label: widget.semanticLabel,
button: widget.onSelect != null,
child: result,
);
}
return result;
}
}