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:
+41
-104
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user