Files
plezy/lib/widgets/focusable_list_tile.dart
T
edde746 961e9c0326 feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.

The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
2026-08-06 03:45:09 +02:00

373 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_tile_mixin.dart';
import '../utils/platform_detector.dart';
import 'clickable_cursor.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 {
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;
/// If true, consumes the first select key event to avoid accidental activation.
final bool suppressInitialSelect;
final Color? hoverColor;
final Color? textColor;
final Color? iconColor;
final VisualDensity? visualDensity;
final double? horizontalTitleGap;
final double? minLeadingWidth;
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),
this.horizontalTitleGap,
this.minLeadingWidth,
});
@override
State<FocusableListTile> createState() => _FocusableListTileState();
}
class _FocusableListTileState extends State<FocusableListTile> with FocusableTileStateMixin<FocusableListTile> {
bool _suppressionConsumed = false;
bool _isHoveredOrFocused = false;
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@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;
final automotive = PlatformDetector.isAutomotive();
final Widget tile = MouseRegion(
cursor: widget.enabled && (widget.onTap != null || widget.onLongPress != null)
? SystemMouseCursors.click
: MouseCursor.defer,
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: automotive ? false : widget.dense,
enabled: widget.enabled,
selected: widget.selected,
contentPadding: widget.contentPadding,
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
focusNode: widget.suppressInitialSelect ? null : effectiveFocusNode,
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
hoverColor: widget.hoverColor,
textColor: textColor,
iconColor: iconColor,
horizontalTitleGap: widget.horizontalTitleGap,
minLeadingWidth: widget.minLeadingWidth,
),
);
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>>
with FocusableTileStateMixin<FocusableRadioListTile<T>> {
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@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,
),
);
}
}
/// 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;
/// Content padding, e.g. to align with sibling rows. Null uses the
/// SwitchListTile default.
final EdgeInsetsGeometry? contentPadding;
/// Horizontal gap between the leading/secondary widget and title.
final double? horizontalTitleGap;
/// Minimum width reserved for the leading/secondary widget.
final double? minLeadingWidth;
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),
this.contentPadding,
this.horizontalTitleGap,
this.minLeadingWidth,
});
@override
State<FocusableSwitchListTile> createState() => _FocusableSwitchListTileState();
}
class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
with FocusableTileStateMixin<FocusableSwitchListTile> {
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@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,
horizontalTitleGap: widget.horizontalTitleGap,
minLeadingWidth: widget.minLeadingWidth,
),
);
}
}
/// 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();
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,
contentPadding: widget.contentPadding,
focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
controlAffinity: widget.controlAffinity,
),
);
}
}