Files
plezy/lib/widgets/focusable_list_tile.dart
T

411 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../focus/dpad_navigator.dart';
import '../utils/scroll_utils.dart';
/// A ListTile that accepts a FocusNode for keyboard/controller navigation.
///
/// Uses Flutter's native ListTile focus support - no custom styling wrapper.
/// The focusNode allows programmatic focus control (e.g., auto-focus first item).
class FocusableListTile extends StatefulWidget {
/// The primary content of the list tile.
final Widget? title;
/// Additional content displayed below the title.
final Widget? subtitle;
/// A widget to display before the title.
final Widget? leading;
/// A widget to display after the title.
final Widget? trailing;
/// Called when the user taps this list tile.
final VoidCallback? onTap;
/// Called when the user long-presses this list tile.
final VoidCallback? onLongPress;
/// Whether this list tile is part of a vertically dense list.
final bool dense;
/// Whether this list tile is interactive.
final bool enabled;
/// If true, the tile is rendered with a selected highlight.
final bool selected;
/// Optional FocusNode for keyboard/controller navigation.
final FocusNode? focusNode;
/// Whether this tile should autofocus when first built.
final bool autofocus;
/// The tile's internal padding.
final EdgeInsetsGeometry? contentPadding;
/// If true, consumes the first select key event to avoid accidental activation.
final bool suppressInitialSelect;
/// An optional color to display behind the menu item when being hovered.
final Color? hoverColor;
/// An optional color for the text of the list tile.
final Color? textColor;
/// An optional color for the icon of the list tile.
final Color? iconColor;
/// Visual density for the list tile.
final VisualDensity? visualDensity;
const FocusableListTile({
super.key,
this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.onLongPress,
this.dense = true,
this.enabled = true,
this.selected = false,
this.focusNode,
this.autofocus = false,
this.contentPadding,
this.suppressInitialSelect = false,
this.hoverColor,
this.textColor,
this.iconColor,
this.visualDensity = const VisualDensity(vertical: -3),
});
@override
State<FocusableListTile> createState() => _FocusableListTileState();
}
class _FocusableListTileState extends State<FocusableListTile> {
bool _suppressionConsumed = false;
bool _isHoveredOrFocused = false;
late FocusNode _effectiveFocusNode;
bool _ownsNode = false;
@override
void initState() {
super.initState();
_initFocusNode();
}
@override
void didUpdateWidget(FocusableListTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.focusNode != oldWidget.focusNode) {
_disposeFocusNode();
_initFocusNode();
}
}
@override
void dispose() {
_disposeFocusNode();
super.dispose();
}
void _initFocusNode() {
if (widget.focusNode != null) {
_effectiveFocusNode = widget.focusNode!;
_ownsNode = false;
} else {
_effectiveFocusNode = FocusNode();
_ownsNode = true;
}
_effectiveFocusNode.addListener(_onFocusChange);
}
void _disposeFocusNode() {
_effectiveFocusNode.removeListener(_onFocusChange);
if (_ownsNode) _effectiveFocusNode.dispose();
}
void _onFocusChange() {
if (_effectiveFocusNode.hasFocus) {
scrollContextToCenter(context);
}
}
@override
Widget build(BuildContext context) {
// When hovered/focused with a custom hoverColor, use onError-style foreground
// to keep text readable against the colored background.
final needsContrastSwap = _isHoveredOrFocused && widget.hoverColor != null && widget.textColor != null;
final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor;
final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor;
Widget tile = MouseRegion(
onEnter: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = true) : null,
onExit: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = false) : null,
child: ListTile(
title: widget.title,
subtitle: widget.subtitle,
leading: widget.leading,
trailing: widget.trailing,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
dense: widget.dense,
enabled: widget.enabled,
selected: widget.selected,
contentPadding: widget.contentPadding,
visualDensity: widget.visualDensity,
focusNode: widget.suppressInitialSelect ? null : _effectiveFocusNode,
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
hoverColor: widget.hoverColor,
textColor: textColor,
iconColor: iconColor,
),
);
if (!widget.suppressInitialSelect) {
return tile;
}
return Focus(
focusNode: _effectiveFocusNode,
autofocus: widget.autofocus,
onKeyEvent: (node, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
if (!_suppressionConsumed && event.logicalKey.isSelectKey) {
_suppressionConsumed = true;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: tile,
);
}
}
/// A RadioListTile that accepts a FocusNode for keyboard/controller navigation.
///
/// Uses Flutter's native RadioListTile focus support - no custom styling wrapper.
/// Requires a [RadioGroup] ancestor to manage selection state.
class FocusableRadioListTile<T> extends StatefulWidget {
/// The primary content of the list tile.
final Widget? title;
/// Additional content displayed below the title.
final Widget? subtitle;
/// A widget to display on the opposite side from the radio.
final Widget? secondary;
/// The value represented by this radio button.
final T value;
/// Whether this radio button is part of a vertically dense list.
final bool dense;
/// Optional FocusNode for keyboard/controller navigation.
final FocusNode? focusNode;
/// Whether this tile should autofocus when first built.
final bool autofocus;
/// Whether the radio tile is interactive.
final bool? enabled;
/// Visual density for the list tile.
final VisualDensity? visualDensity;
const FocusableRadioListTile({
super.key,
this.title,
this.subtitle,
this.secondary,
required this.value,
this.dense = true,
this.focusNode,
this.autofocus = false,
this.enabled,
this.visualDensity = const VisualDensity(vertical: -3),
});
@override
State<FocusableRadioListTile<T>> createState() => _FocusableRadioListTileState<T>();
}
class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>> {
late FocusNode _effectiveFocusNode;
bool _ownsNode = false;
@override
void initState() {
super.initState();
_initFocusNode();
}
@override
void didUpdateWidget(FocusableRadioListTile<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.focusNode != oldWidget.focusNode) {
_disposeFocusNode();
_initFocusNode();
}
}
@override
void dispose() {
_disposeFocusNode();
super.dispose();
}
void _initFocusNode() {
if (widget.focusNode != null) {
_effectiveFocusNode = widget.focusNode!;
_ownsNode = false;
} else {
_effectiveFocusNode = FocusNode();
_ownsNode = true;
}
_effectiveFocusNode.addListener(_onFocusChange);
}
void _disposeFocusNode() {
_effectiveFocusNode.removeListener(_onFocusChange);
if (_ownsNode) _effectiveFocusNode.dispose();
}
void _onFocusChange() {
if (_effectiveFocusNode.hasFocus) {
scrollContextToCenter(context);
}
}
@override
Widget build(BuildContext context) {
return RadioListTile<T>(
title: widget.title,
subtitle: widget.subtitle,
secondary: widget.secondary,
value: widget.value,
// groupValue and onChanged provided by RadioGroup ancestor
dense: widget.dense,
visualDensity: widget.visualDensity,
focusNode: _effectiveFocusNode,
autofocus: widget.autofocus,
enabled: widget.enabled,
);
}
}
/// A SwitchListTile that accepts a FocusNode for keyboard/controller navigation.
///
/// Uses Flutter's native SwitchListTile focus support - no custom styling wrapper.
class FocusableSwitchListTile extends StatefulWidget {
/// The primary content of the list tile.
final Widget? title;
/// Additional content displayed below the title.
final Widget? subtitle;
/// A widget to display on the opposite side from the switch.
final Widget? secondary;
/// Whether this switch is checked.
final bool value;
/// Called when the user toggles the switch.
final ValueChanged<bool>? onChanged;
/// Whether this switch is part of a vertically dense list.
final bool dense;
/// Optional FocusNode for keyboard/controller navigation.
final FocusNode? focusNode;
/// Whether this tile should autofocus when first built.
final bool autofocus;
/// Visual density for the list tile.
final VisualDensity? visualDensity;
const FocusableSwitchListTile({
super.key,
this.title,
this.subtitle,
this.secondary,
required this.value,
required this.onChanged,
this.dense = true,
this.focusNode,
this.autofocus = false,
this.visualDensity = const VisualDensity(vertical: -3),
});
@override
State<FocusableSwitchListTile> createState() => _FocusableSwitchListTileState();
}
class _FocusableSwitchListTileState extends State<FocusableSwitchListTile> {
late FocusNode _effectiveFocusNode;
bool _ownsNode = false;
@override
void initState() {
super.initState();
_initFocusNode();
}
@override
void didUpdateWidget(FocusableSwitchListTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.focusNode != oldWidget.focusNode) {
_disposeFocusNode();
_initFocusNode();
}
}
@override
void dispose() {
_disposeFocusNode();
super.dispose();
}
void _initFocusNode() {
if (widget.focusNode != null) {
_effectiveFocusNode = widget.focusNode!;
_ownsNode = false;
} else {
_effectiveFocusNode = FocusNode();
_ownsNode = true;
}
_effectiveFocusNode.addListener(_onFocusChange);
}
void _disposeFocusNode() {
_effectiveFocusNode.removeListener(_onFocusChange);
if (_ownsNode) _effectiveFocusNode.dispose();
}
void _onFocusChange() {
if (_effectiveFocusNode.hasFocus) {
scrollContextToCenter(context);
}
}
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: widget.title,
subtitle: widget.subtitle,
secondary: widget.secondary,
value: widget.value,
onChanged: widget.onChanged,
dense: widget.dense,
visualDensity: widget.visualDensity,
focusNode: _effectiveFocusNode,
autofocus: widget.autofocus,
);
}
}