refactor: share focus chrome and simplify the TV picker and browse paths

Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.

Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
This commit is contained in:
edde746
2026-07-26 06:09:49 +02:00
parent c68ffe9ed0
commit 4eaf4423a1
47 changed files with 526 additions and 1133 deletions
+41 -104
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../focus/card_focus_scope.dart';
import '../focus/focus_glow_overlay.dart';
import '../focus/focus_chrome.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import 'clickable_cursor.dart';
@@ -63,96 +62,13 @@ class FocusBuilders {
);
}
/// Builds a card-style focusable widget with scale and border decoration.
/// Builds a card-style wrapper with scale and border decoration but no [Focus]
/// node — focus lives on an enclosing rail or screen that passes [isFocused]
/// down.
///
/// Used by FocusableMediaCard and _LockedHubItemWrapper.
///
/// Parameters:
/// - [context]: Build context for theming
/// - [focusNode]: The focus node for this widget (optional for locked wrappers)
/// - [isFocused]: Whether this widget currently has focus
/// - [onKeyEvent]: Callback for handling key events (optional for locked wrappers)
/// - [onTap]: Callback for tap/click events
/// - [onLongPress]: Callback for long press events
/// - [borderRadius]: Border radius for the focus decoration
/// - [child]: The content to display inside the card
static Widget buildFocusableCard({
required BuildContext context,
FocusNode? focusNode,
required bool isFocused,
KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent,
VoidCallback? onTap,
VoidCallback? onLongPress,
double borderRadius = FocusTheme.defaultBorderRadius,
double focusScale = FocusTheme.focusScale,
bool useFocusGlow = false,
bool delegateFocusBorder = false,
Size? glowSize,
required Widget child,
}) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
// In touch mode, no item ever shows focus effects — skip animated wrappers
// entirely. This saves ~2 element levels per card on ARM32 Android phones.
if (!isKeyboardMode) {
final gestureWidget = (onTap != null || onLongPress != null)
? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child),
)
: child;
if (focusNode != null && onKeyEvent != null) {
return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget);
}
return gestureWidget;
}
final duration = FocusTheme.getAnimationDuration(context);
final showFocus = isFocused && isKeyboardMode;
// Glow (full-bleed cards) renders in an overlay above siblings so it stays
// symmetric; the in-card decoration only carries the border.
Widget card = delegateFocusBorder
? CardFocusScope(showFocus: showFocus, child: child)
: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius),
child: child,
);
if (useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
borderRadius: borderRadius,
color: FocusTheme.getFocusBorderColor(context),
glowSize: glowSize,
child: card,
);
}
final focusedWidget = AnimatedScale(
scale: showFocus ? focusScale : 1.0,
duration: duration,
curve: Curves.easeOutCubic,
child: card,
);
// Wrap in GestureDetector if tap/long press handlers provided
final gestureWidget = (onTap != null || onLongPress != null)
? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget),
)
: focusedWidget;
// Wrap in Focus if focus node and key event handler provided
if (focusNode != null && onKeyEvent != null) {
return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget);
}
return gestureWidget;
}
/// Builds a simple locked wrapper (no Focus widget) with scale and border decoration.
///
/// Used by _LockedHubItemWrapper where focus is managed at a higher level.
/// Used by the hub row, the TV browse rail, the cast strip and the extras row.
/// Cards that own their focus node use [FocusableWrapper] instead; both share
/// the same chrome through [buildFocusChrome].
///
/// Parameters:
/// - [context]: Build context for theming
@@ -173,19 +89,40 @@ class FocusBuilders {
Size? glowSize,
required Widget child,
}) {
return buildFocusableCard(
context: context,
focusNode: null,
isFocused: isFocused,
onKeyEvent: null,
onTap: onTap,
onLongPress: onLongPress,
borderRadius: borderRadius,
focusScale: focusScale,
useFocusGlow: useFocusGlow,
delegateFocusBorder: delegateFocusBorder,
glowSize: glowSize,
child: child,
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
// In touch mode, no item ever shows focus effects — skip animated wrappers
// entirely. This saves ~2 element levels per card on ARM32 Android phones.
if (!isKeyboardMode) {
return (onTap != null || onLongPress != null)
? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child),
)
: child;
}
final duration = FocusTheme.getAnimationDuration(context);
final focusedWidget = AnimatedScale(
scale: isFocused ? focusScale : 1.0,
duration: duration,
curve: Curves.easeOutCubic,
child: buildFocusChrome(
context,
showFocus: isFocused,
duration: duration,
borderRadius: borderRadius,
useFocusGlow: useFocusGlow,
delegateFocusBorder: delegateFocusBorder,
glowSize: glowSize,
child: child,
),
);
// Wrap in GestureDetector if tap/long press handlers provided
return (onTap != null || onLongPress != null)
? ClickableCursor(
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget),
)
: focusedWidget;
}
}
+64 -120
View File
@@ -54,6 +54,20 @@ final class _LibraryItemRow extends _LibraryNavRow {
const _LibraryItemRow({required super.section, required this.library, this.showServerName = false});
}
/// SELECT activates the rail row, RIGHT hands off to the content area.
KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelect, VoidCallback? onNavigateRight}) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey.isSelectKey) {
onSelect();
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
onNavigateRight();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Reusable navigation rail item widget that handles focus, selection, and interaction
class NavigationRailItem extends StatelessWidget {
final IconData icon;
@@ -63,6 +77,9 @@ class NavigationRailItem extends StatelessWidget {
/// Playing item's equalizer). Should be at most [iconSize] tall/wide.
final Widget? iconWidget;
final Widget label;
/// Widget rendered after the [label] (e.g. a section header's chevron).
final Widget? trailing;
final bool isSelected;
final bool isCollapsed;
final bool useSimpleLayout;
@@ -74,6 +91,11 @@ class NavigationRailItem extends StatelessWidget {
final double horizontalPadding;
final bool suppressSelectedBackground;
/// Background tint while keyboard-focused, and its stronger variant used
/// when the item also shows its selected background.
final double focusAlpha;
final double selectedFocusAlpha;
/// Called when RIGHT arrow is pressed to navigate to content area.
final VoidCallback? onNavigateRight;
@@ -83,6 +105,7 @@ class NavigationRailItem extends StatelessWidget {
this.selectedIcon,
this.iconWidget,
required this.label,
this.trailing,
required this.isSelected,
this.isCollapsed = false,
this.useSimpleLayout = false,
@@ -93,6 +116,8 @@ class NavigationRailItem extends StatelessWidget {
this.iconSize = 22,
this.horizontalPadding = 17,
this.suppressSelectedBackground = false,
this.focusAlpha = 0.12,
this.selectedFocusAlpha = 0.15,
this.onNavigateRight,
});
@@ -108,18 +133,7 @@ class NavigationRailItem extends StatelessWidget {
return Focus(
focusNode: focusNode,
autofocus: autofocus,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey.isSelectKey) {
onTap();
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
onNavigateRight!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onTap, onNavigateRight: onNavigateRight),
child: Material(
color: Colors.transparent,
child: InkWell(
@@ -129,8 +143,10 @@ class NavigationRailItem extends StatelessWidget {
child: Container(
decoration: BoxDecoration(
color: () {
if (isCollapsed) return focused ? t.text.withValues(alpha: 0.12) : null;
if (focused) return t.text.withValues(alpha: showSelectedBackground ? 0.15 : 0.12);
if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null;
if (focused) {
return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha);
}
if (showSelectedBackground) return t.text.withValues(alpha: 0.1);
return null;
}(),
@@ -162,6 +178,7 @@ class NavigationRailItem extends StatelessWidget {
return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label);
}(),
),
?trailing,
],
),
),
@@ -975,107 +992,44 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
}) {
final librariesProvider = context.watch<LibrariesProvider>();
final isLoading = librariesProvider.isLoading;
final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null;
final librariesFocusNode = _focusTracker.get(_kLibraries);
final showLibrariesSelectedBackground = isLibrariesSelected && !widget.isSidebarFocused;
final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries;
final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0;
return Column(
crossAxisAlignment: .start,
children: [
ListenableBuilder(
listenable: librariesFocusNode,
builder: (context, _) => Focus(
focusNode: librariesFocusNode,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey.isSelectKey) {
setState(() {
_librariesExpanded = !_librariesExpanded;
});
return KeyEventResult.handled;
}
// RIGHT arrow navigates to content area
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
widget.onNavigateToContent!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Material(
color: Colors.transparent,
child: InkWell(
canRequestFocus: false,
onTap: () {
setState(() {
_librariesExpanded = !_librariesExpanded;
});
},
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
child: Container(
decoration: BoxDecoration(
color: () {
final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null;
if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1);
if (showFocus) return t.text.withValues(alpha: 0.08);
return null;
}(),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
),
clipBehavior: Clip.hardEdge,
child: UnconstrainedBox(
alignment: .centerLeft,
constrainedAxis: Axis.vertical,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: expandedWidth - 24,
child: Padding(
padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding),
child: Row(
children: [
AppIcon(
Symbols.video_library_rounded,
fill: 1,
size: 22,
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
),
const SizedBox(width: 11),
Expanded(
child: AnimatedOpacity(
opacity: isCollapsed ? 0.0 : 1.0,
duration: tokens(context).fast,
child: Text(
Translations.of(context).navigation.libraries,
style: TextStyle(
fontSize: 14,
fontWeight: widget.selectedTab == NavigationTabId.libraries
? FontWeight.w600
: FontWeight.w400,
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
),
),
),
),
AnimatedOpacity(
opacity: isCollapsed ? 0.0 : 1.0,
duration: tokens(context).fast,
child: AppIcon(
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
fill: 1,
size: 20,
color: t.textMuted,
),
),
],
),
),
),
),
),
),
NavigationRailItem(
icon: Symbols.video_library_rounded,
label: Text(
Translations.of(context).navigation.libraries,
style: TextStyle(
fontSize: 14,
fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400,
color: isLibrariesTabSelected ? t.text : t.textMuted,
),
),
trailing: AnimatedOpacity(
opacity: isCollapsed ? 0.0 : 1.0,
duration: tokens(context).fast,
child: AppIcon(
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
fill: 1,
size: 20,
color: t.textMuted,
),
),
isSelected: isLibrariesTabSelected,
isCollapsed: isCollapsed,
onTap: () => setState(() => _librariesExpanded = !_librariesExpanded),
focusNode: _focusTracker.get(_kLibraries),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
horizontalPadding: itemHorizontalPadding,
// A selected library owns the highlight; the header only shows it
// for the bare Libraries tab.
suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null,
focusAlpha: 0.08,
selectedFocusAlpha: 0.1,
onNavigateRight: widget.onNavigateToContent,
),
TweenAnimationBuilder<double>(
@@ -1222,18 +1176,8 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
listenable: focusNode,
builder: (context, _) => Focus(
focusNode: focusNode,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey.isSelectKey) {
onToggle();
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
widget.onNavigateToContent!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
onKeyEvent: (node, event) =>
_handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent),
child: Material(
color: Colors.transparent,
child: InkWell(
+29 -226
View File
@@ -3,14 +3,9 @@ import 'package:flutter/services.dart';
import '../i18n/strings.g.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/focusable_text_field.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_repeat_helper.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../theme/mono_tokens.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'app_icon.dart';
import 'tv_number_spinner.dart';
/// A TV-friendly color picker using HSV sliders for D-pad navigation.
///
@@ -106,6 +101,31 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
widget.onColorChanged(color);
}
Widget _channelRow({
required String label,
required String semanticLabel,
required int value,
required int max,
required String suffix,
required ValueChanged<int> onChanged,
bool autofocus = false,
}) {
return TvNumberSpinner(
label: label,
semanticLabel: semanticLabel,
value: value,
min: 0,
max: max,
step: 5,
suffix: suffix,
autofocus: autofocus,
onConfirm: widget.onConfirm,
onChanged: onChanged,
verticalKeysAdjustValue: false,
density: TvNumberSpinnerDensity.compact,
);
}
@override
Widget build(BuildContext context) {
final currentColor = _currentColor();
@@ -123,46 +143,37 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
),
),
const SizedBox(height: 16),
_ColorChannelRow(
_channelRow(
label: 'H',
semanticLabel: Translations.of(context).accessibility.hue,
value: _hue,
min: 0,
max: 360,
step: 5,
suffix: '°',
autofocus: true,
onConfirm: widget.onConfirm,
onChanged: (v) {
setState(() => _hue = v);
_onChannelChanged();
},
),
const SizedBox(height: 8),
_ColorChannelRow(
_channelRow(
label: 'S',
semanticLabel: Translations.of(context).accessibility.saturation,
value: _saturation,
min: 0,
max: 100,
step: 5,
suffix: '%',
onConfirm: widget.onConfirm,
onChanged: (v) {
setState(() => _saturation = v);
_onChannelChanged();
},
),
const SizedBox(height: 8),
_ColorChannelRow(
_channelRow(
label: 'V',
semanticLabel: Translations.of(context).accessibility.brightness,
value: _value,
min: 0,
max: 100,
step: 5,
suffix: '%',
onConfirm: widget.onConfirm,
onChanged: (v) {
setState(() => _value = v);
_onChannelChanged();
@@ -185,211 +196,3 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
);
}
}
/// A horizontal channel row for a single HSV component.
///
/// LEFT/RIGHT adjust the value (with repeat timer for held keys).
/// UP/DOWN are ignored so focus traverses normally between rows.
class _ColorChannelRow extends StatefulWidget {
final String label;
final String semanticLabel;
final int value;
final int min;
final int max;
final int step;
final String suffix;
final bool autofocus;
final ValueChanged<int> onChanged;
/// Called when the user presses SELECT to confirm.
final VoidCallback? onConfirm;
const _ColorChannelRow({
required this.label,
required this.semanticLabel,
required this.value,
required this.min,
required this.max,
required this.step,
required this.suffix,
required this.onChanged,
this.autofocus = false,
this.onConfirm,
});
@override
State<_ColorChannelRow> createState() => _ColorChannelRowState();
}
class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper<_ColorChannelRow> {
late FocusNode _focusNode;
bool _isFocused = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'ColorChannel_${widget.label}');
}
@override
void dispose() {
stopRepeat();
_focusNode.dispose();
super.dispose();
}
void _increment() {
final newValue = widget.value + widget.step;
if (newValue <= widget.max) {
widget.onChanged(newValue);
}
}
void _decrement() {
final newValue = widget.value - widget.step;
if (newValue >= widget.min) {
widget.onChanged(newValue);
}
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
// Let UP/DOWN pass through for focus traversal between rows
if (key.isUpKey || key.isDownKey) {
return KeyEventResult.ignored;
}
if (event is KeyDownEvent) {
if (key.isSelectKey && widget.onConfirm != null) {
widget.onConfirm!();
return KeyEventResult.handled;
}
if (key.isRightKey) {
startRepeat(_increment);
return KeyEventResult.handled;
} else if (key.isLeftKey) {
startRepeat(_decrement);
return KeyEventResult.handled;
}
} else if (event is KeyRepeatEvent) {
// Consume repeat events for LEFT/RIGHT so they don't escape
// to the focus system as traversal actions. The repeat timer
// from KeyDown already handles value repetition.
if (key.isRightKey || key.isLeftKey) {
return KeyEventResult.handled;
}
} else if (event is KeyUpEvent) {
if (key.isRightKey || key.isLeftKey) {
stopRepeat();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = theme.extension<MonoTokens>();
final canDecrement = widget.value > widget.min;
final canIncrement = widget.value < widget.max;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
return Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
descendantsAreFocusable: false,
onFocusChange: (hasFocus) {
setState(() => _isFocused = hasFocus);
if (!hasFocus) stopRepeat();
},
onKeyEvent: _handleKeyEvent,
child: AnimatedContainer(
duration: tokens?.fast ?? const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
border: Border.fromBorderSide(
BorderSide(
color: _isFocused && isKeyboardMode ? FocusTheme.getFocusBorderColor(context) : Colors.transparent,
width: FocusTheme.focusBorderWidth,
),
),
),
child: Row(
children: [
SizedBox(
width: 24,
child: Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)),
),
const SizedBox(width: 8),
_ChannelButton(
icon: Symbols.remove_rounded,
onPressed: canDecrement ? _decrement : null,
semanticLabel: Translations.of(context).accessibility.decreaseValue(label: widget.semanticLabel),
),
const SizedBox(width: 8),
Container(
constraints: const BoxConstraints(minWidth: 56),
alignment: .center,
child: Text('${widget.value}${widget.suffix}', style: theme.textTheme.titleMedium),
),
const SizedBox(width: 8),
_ChannelButton(
icon: Symbols.add_rounded,
onPressed: canIncrement ? _increment : null,
semanticLabel: Translations.of(context).accessibility.increaseValue(label: widget.semanticLabel),
),
],
),
),
);
}
}
class _ChannelButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
final String semanticLabel;
const _ChannelButton({required this.icon, required this.onPressed, required this.semanticLabel});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isEnabled = onPressed != null;
return Semantics(
label: semanticLabel,
button: true,
enabled: isEnabled,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest,
),
child: Center(
child: AppIcon(
icon,
size: 18,
fill: 1,
color: isEnabled
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
),
),
),
),
);
}
}
+110 -47
View File
@@ -11,10 +11,20 @@ import 'app_icon.dart';
import '../theme/mono_tokens.dart';
import 'package:material_symbols_icons/symbols.dart';
/// Size variant for [TvNumberSpinner].
enum TvNumberSpinnerDensity {
/// Large buttons with long-press repeat, for a spinner that owns the dialog.
standard,
/// Smaller buttons sized to sit in a stack of labelled rows.
compact,
}
/// A TV-friendly number spinner with +/- buttons for D-pad navigation.
///
/// Displays a value with decrement/increment buttons on either side.
/// Supports keyboard repeat for faster value changes when holding arrows.
/// Displays a value with decrement/increment buttons on either side, optionally
/// behind a leading [label]. Supports keyboard repeat for faster value changes
/// when holding arrows.
class TvNumberSpinner extends StatefulWidget {
final int value;
@@ -27,6 +37,13 @@ class TvNumberSpinner extends StatefulWidget {
/// Optional suffix text (e.g., "s" for seconds).
final String? suffix;
/// Optional leading label shown before the buttons (e.g., "H" for hue).
final String? label;
/// When set, the +/- buttons announce themselves as adjusting this value
/// instead of using the generic increase/decrease labels.
final String? semanticLabel;
final ValueChanged<int> onChanged;
/// Called when the user presses SELECT to confirm.
@@ -39,6 +56,13 @@ class TvNumberSpinner extends StatefulWidget {
final bool autofocus;
/// When false, UP/DOWN are left alone so focus traverses between rows, and
/// held LEFT/RIGHT repeat events are consumed so they don't escape to the
/// focus system as traversal actions.
final bool verticalKeysAdjustValue;
final TvNumberSpinnerDensity density;
const TvNumberSpinner({
super.key,
required this.value,
@@ -47,9 +71,13 @@ class TvNumberSpinner extends StatefulWidget {
required this.onChanged,
this.step = 1,
this.suffix,
this.label,
this.semanticLabel,
this.autofocus = false,
this.onConfirm,
this.onCancel,
this.verticalKeysAdjustValue = true,
this.density = TvNumberSpinnerDensity.standard,
});
@override
@@ -63,7 +91,8 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'TvNumberSpinner');
final label = widget.label;
_focusNode = FocusNode(debugLabel: label == null ? 'TvNumberSpinner' : 'TvNumberSpinner_$label');
}
@override
@@ -89,6 +118,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final vertical = widget.verticalKeysAdjustValue;
if (widget.onCancel != null) {
final backResult = handleBackKeyAction(event, widget.onCancel!);
@@ -97,20 +127,32 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
}
}
// Let UP/DOWN pass through for focus traversal between rows.
if (!vertical && (key.isUpKey || key.isDownKey)) {
return KeyEventResult.ignored;
}
if (event is KeyDownEvent) {
if (key.isSelectKey && widget.onConfirm != null) {
widget.onConfirm!();
return KeyEventResult.handled;
}
if (key.isUpKey || key.isRightKey) {
if ((vertical && key.isUpKey) || key.isRightKey) {
startRepeat(_increment);
return KeyEventResult.handled;
} else if (key.isDownKey || key.isLeftKey) {
} else if ((vertical && key.isDownKey) || key.isLeftKey) {
startRepeat(_decrement);
return KeyEventResult.handled;
}
} else if (event is KeyRepeatEvent) {
// The repeat timer from KeyDown already handles value repetition, so
// swallow the OS repeats that would otherwise traverse focus. Only
// needed when UP/DOWN traverse — otherwise no direction escapes.
if (!vertical && (key.isRightKey || key.isLeftKey)) {
return KeyEventResult.handled;
}
} else if (event is KeyUpEvent) {
if (key.isUpKey || key.isRightKey || key.isDownKey || key.isLeftKey) {
if ((vertical && (key.isUpKey || key.isDownKey)) || key.isRightKey || key.isLeftKey) {
stopRepeat();
return KeyEventResult.handled;
}
@@ -126,6 +168,11 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
final canDecrement = widget.value > widget.min;
final canIncrement = widget.value < widget.max;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final isCompact = widget.density == TvNumberSpinnerDensity.compact;
final gap = isCompact ? const SizedBox(width: 8) : const SizedBox(width: 16);
final label = widget.label;
final semanticLabel = widget.semanticLabel;
final a11y = Translations.of(context).accessibility;
return Focus(
focusNode: _focusNode,
@@ -149,32 +196,43 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
),
),
child: Row(
mainAxisSize: .min,
mainAxisAlignment: .center,
mainAxisSize: isCompact ? .max : .min,
mainAxisAlignment: isCompact ? .start : .center,
children: [
if (label != null) ...[
SizedBox(
width: 24,
child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)),
),
gap,
],
_SpinnerButton(
icon: Symbols.remove_rounded,
onPressed: canDecrement ? _decrement : null,
onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null,
onLongPressEnd: stopRepeat,
semanticLabel: Translations.of(context).accessibility.decrease,
onLongPressStart: !isCompact && canDecrement ? () => startRepeat(_decrement) : null,
onLongPressEnd: isCompact ? null : stopRepeat,
semanticLabel: semanticLabel != null ? a11y.decreaseValue(label: semanticLabel) : a11y.decrease,
compact: isCompact,
),
const SizedBox(width: 16),
gap,
Container(
constraints: const BoxConstraints(minWidth: 60),
constraints: BoxConstraints(minWidth: isCompact ? 56 : 60),
alignment: .center,
child: Text(
widget.suffix != null ? '${widget.value}${widget.suffix}' : '${widget.value}',
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold),
'${widget.value}${widget.suffix ?? ''}',
style: isCompact
? theme.textTheme.titleMedium
: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold),
),
),
const SizedBox(width: 16),
gap,
_SpinnerButton(
icon: Symbols.add_rounded,
onPressed: canIncrement ? _increment : null,
onLongPressStart: canIncrement ? () => startRepeat(_increment) : null,
onLongPressEnd: stopRepeat,
semanticLabel: Translations.of(context).accessibility.increase,
onLongPressStart: !isCompact && canIncrement ? () => startRepeat(_increment) : null,
onLongPressEnd: isCompact ? null : stopRepeat,
semanticLabel: semanticLabel != null ? a11y.increaseValue(label: semanticLabel) : a11y.increase,
compact: isCompact,
),
],
),
@@ -190,6 +248,7 @@ class _SpinnerButton extends StatelessWidget {
final VoidCallback? onLongPressStart;
final VoidCallback? onLongPressEnd;
final String semanticLabel;
final bool compact;
const _SpinnerButton({
required this.icon,
@@ -197,45 +256,49 @@ class _SpinnerButton extends StatelessWidget {
this.onLongPressStart,
this.onLongPressEnd,
required this.semanticLabel,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isEnabled = onPressed != null;
final size = compact ? 36.0 : 48.0;
return Semantics(
label: semanticLabel,
button: true,
enabled: isEnabled,
child: GestureDetector(
onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null,
onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest,
),
child: Center(
child: AppIcon(
icon,
fill: 1,
color: isEnabled
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
),
Widget button = Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.all(Radius.circular(compact ? 20 : 24)),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest,
),
child: Center(
child: AppIcon(
icon,
size: compact ? 18 : null,
fill: 1,
color: isEnabled
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
),
),
),
);
if (onLongPressStart != null || onLongPressEnd != null) {
button = GestureDetector(
onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null,
onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null,
child: button,
);
}
return Semantics(label: semanticLabel, button: true, enabled: isEnabled, child: button);
}
}