Files
plezy/lib/widgets/focusable_list_tile.dart
T

326 lines
9.4 KiB
Dart
Raw Normal View History

2025-12-07 22:43:47 +01:00
import 'package:flutter/material.dart';
import '../focus/focusable_tile_mixin.dart';
import '../utils/platform_detector.dart';
import 'clickable_cursor.dart';
2025-12-07 22:43:47 +01:00
/// 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).
2026-01-23 20:24:20 +01:00
class FocusableListTile extends StatefulWidget {
2025-12-07 22:43:47 +01:00
final Widget? title;
final Widget? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool dense;
final bool enabled;
final bool selected;
/// Optional FocusNode for keyboard/controller navigation.
final FocusNode? focusNode;
final bool autofocus;
final EdgeInsetsGeometry? contentPadding;
2026-03-18 09:46:12 +01:00
final VisualDensity? visualDensity;
2026-06-26 01:19:38 +02:00
final double? horizontalTitleGap;
final double? minLeadingWidth;
2025-12-07 22:43:47 +01:00
const FocusableListTile({
super.key,
this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.onLongPress,
2026-03-18 09:46:12 +01:00
this.dense = true,
2025-12-07 22:43:47 +01:00
this.enabled = true,
this.selected = false,
this.focusNode,
this.autofocus = false,
this.contentPadding,
2026-03-18 09:46:12 +01:00
this.visualDensity = const VisualDensity(vertical: -3),
2026-06-26 01:19:38 +02:00
this.horizontalTitleGap,
this.minLeadingWidth,
2025-12-07 22:43:47 +01:00
});
2026-01-23 20:24:20 +01:00
@override
State<FocusableListTile> createState() => _FocusableListTileState();
}
class _FocusableListTileState extends State<FocusableListTile> with FocusableTileStateMixin<FocusableListTile> {
@override
FocusNode? get widgetFocusNode => widget.focusNode;
2025-12-07 22:43:47 +01:00
@override
Widget build(BuildContext context) {
final automotive = PlatformDetector.isAutomotive();
2026-02-28 15:00:59 +01:00
return MouseRegion(
cursor: widget.enabled && (widget.onTap != null || widget.onLongPress != null)
? SystemMouseCursors.click
: MouseCursor.defer,
2026-02-28 15:00:59 +01:00
child: ListTile(
title: widget.title,
subtitle: widget.subtitle,
leading: widget.leading,
trailing: widget.trailing,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
dense: automotive ? false : widget.dense,
2026-02-28 15:00:59 +01:00
enabled: widget.enabled,
selected: widget.selected,
contentPadding: widget.contentPadding,
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
2026-06-26 01:19:38 +02:00
horizontalTitleGap: widget.horizontalTitleGap,
minLeadingWidth: widget.minLeadingWidth,
2026-02-28 15:00:59 +01:00
),
2026-01-23 20:24:20 +01:00
);
2025-12-07 22:43:47 +01:00
}
}
/// A RadioListTile that accepts a FocusNode for keyboard/controller navigation.
///
/// Uses Flutter's native RadioListTile focus support - no custom styling wrapper.
2026-02-23 13:48:23 +01:00
/// Requires a [RadioGroup] ancestor to manage selection state.
class FocusableRadioListTile<T> extends StatefulWidget {
2025-12-07 22:43:47 +01:00
/// 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;
2025-12-13 04:21:39 +01:00
/// Whether the radio tile is interactive.
final bool? enabled;
2026-03-18 09:46:12 +01:00
/// Visual density for the list tile.
final VisualDensity? visualDensity;
2025-12-07 22:43:47 +01:00
const FocusableRadioListTile({
super.key,
this.title,
this.subtitle,
this.secondary,
required this.value,
2026-03-18 09:46:12 +01:00
this.dense = true,
2025-12-07 22:43:47 +01:00
this.focusNode,
this.autofocus = false,
2025-12-13 04:21:39 +01:00
this.enabled,
2026-03-18 09:46:12 +01:00
this.visualDensity = const VisualDensity(vertical: -3),
2025-12-07 22:43:47 +01:00
});
@override
State<FocusableRadioListTile<T>> createState() => _FocusableRadioListTileState<T>();
}
class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
with FocusableTileStateMixin<FocusableRadioListTile<T>> {
@override
FocusNode? get widgetFocusNode => widget.focusNode;
2025-12-07 22:43:47 +01:00
@override
Widget build(BuildContext context) {
final automotive = PlatformDetector.isAutomotive();
return ClickableCursor(
enabled: widget.enabled ?? true,
child: RadioListTile<T>(
title: widget.title,
subtitle: widget.subtitle,
secondary: widget.secondary,
value: widget.value,
// groupValue and onChanged provided by RadioGroup ancestor
dense: automotive ? false : widget.dense,
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
enabled: widget.enabled,
),
2025-12-07 22:43:47 +01:00
);
}
}
/// 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 {
2025-12-07 22:43:47 +01:00
/// 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;
2026-03-18 09:46:12 +01:00
/// Visual density for the list tile.
final VisualDensity? visualDensity;
/// Content padding, e.g. to align with sibling rows. Null uses the
/// SwitchListTile default.
final EdgeInsetsGeometry? contentPadding;
2026-06-26 01:19:38 +02:00
/// Horizontal gap between the leading/secondary widget and title.
final double? horizontalTitleGap;
/// Minimum width reserved for the leading/secondary widget.
final double? minLeadingWidth;
2025-12-07 22:43:47 +01:00
const FocusableSwitchListTile({
super.key,
this.title,
this.subtitle,
this.secondary,
required this.value,
required this.onChanged,
2026-03-18 09:46:12 +01:00
this.dense = true,
2025-12-07 22:43:47 +01:00
this.focusNode,
this.autofocus = false,
2026-03-18 09:46:12 +01:00
this.visualDensity = const VisualDensity(vertical: -3),
this.contentPadding,
2026-06-26 01:19:38 +02:00
this.horizontalTitleGap,
this.minLeadingWidth,
2025-12-07 22:43:47 +01:00
});
@override
State<FocusableSwitchListTile> createState() => _FocusableSwitchListTileState();
}
class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
with FocusableTileStateMixin<FocusableSwitchListTile> {
@override
FocusNode? get widgetFocusNode => widget.focusNode;
2025-12-07 22:43:47 +01:00
@override
Widget build(BuildContext context) {
final automotive = PlatformDetector.isAutomotive();
return ClickableCursor(
enabled: widget.onChanged != null,
child: SwitchListTile(
title: widget.title,
subtitle: widget.subtitle,
secondary: widget.secondary,
value: widget.value,
onChanged: widget.onChanged,
dense: automotive ? false : widget.dense,
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
contentPadding: widget.contentPadding,
focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
2026-06-26 01:19:38 +02:00
horizontalTitleGap: widget.horizontalTitleGap,
minLeadingWidth: widget.minLeadingWidth,
),
2025-12-07 22:43:47 +01:00
);
}
}
2026-07-13 05:56:24 +02:00
/// A CheckboxListTile that accepts a FocusNode for keyboard/controller navigation.
///
/// Uses Flutter's native CheckboxListTile focus support - no custom styling wrapper.
class FocusableCheckboxListTile extends StatefulWidget {
final Widget? title;
final Widget? subtitle;
final Widget? secondary;
final bool? value;
final ValueChanged<bool?>? onChanged;
final bool tristate;
final bool dense;
final FocusNode? focusNode;
final bool autofocus;
final VisualDensity? visualDensity;
final EdgeInsetsGeometry? contentPadding;
final ListTileControlAffinity controlAffinity;
const FocusableCheckboxListTile({
super.key,
this.title,
this.subtitle,
this.secondary,
required this.value,
required this.onChanged,
this.tristate = false,
this.dense = true,
this.focusNode,
this.autofocus = false,
this.visualDensity = const VisualDensity(vertical: -3),
this.contentPadding,
this.controlAffinity = ListTileControlAffinity.platform,
});
@override
State<FocusableCheckboxListTile> createState() => _FocusableCheckboxListTileState();
}
class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
with FocusableTileStateMixin<FocusableCheckboxListTile> {
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@override
Widget build(BuildContext context) {
final automotive = PlatformDetector.isAutomotive();
2026-07-13 05:56:24 +02:00
return ClickableCursor(
enabled: widget.onChanged != null,
child: CheckboxListTile(
title: widget.title,
subtitle: widget.subtitle,
secondary: widget.secondary,
value: widget.value,
onChanged: widget.onChanged,
tristate: widget.tristate,
dense: automotive ? false : widget.dense,
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
2026-07-13 05:56:24 +02:00
contentPadding: widget.contentPadding,
focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
controlAffinity: widget.controlAffinity,
),
);
}
}