Files
plezy/lib/widgets/tv_number_spinner.dart
T
edde746 4eaf4423a1 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.
2026-07-26 06:09:49 +02:00

305 lines
9.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../i18n/strings.g.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../focus/key_repeat_helper.dart';
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, optionally
/// behind a leading [label]. Supports keyboard repeat for faster value changes
/// when holding arrows.
class TvNumberSpinner extends StatefulWidget {
final int value;
final int min;
final int max;
final int step;
/// 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.
/// Use this to move focus to a confirm/save button.
final VoidCallback? onConfirm;
/// Called when the user presses BACK to cancel.
/// Use this to close the dialog or cancel the operation.
final VoidCallback? onCancel;
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,
required this.min,
required this.max,
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
State<TvNumberSpinner> createState() => _TvNumberSpinnerState();
}
class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<TvNumberSpinner> {
late FocusNode _focusNode;
bool _isFocused = false;
@override
void initState() {
super.initState();
final label = widget.label;
_focusNode = FocusNode(debugLabel: label == null ? 'TvNumberSpinner' : 'TvNumberSpinner_$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;
final vertical = widget.verticalKeysAdjustValue;
if (widget.onCancel != null) {
final backResult = handleBackKeyAction(event, widget.onCancel!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
}
// 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 ((vertical && key.isUpKey) || key.isRightKey) {
startRepeat(_increment);
return KeyEventResult.handled;
} 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 ((vertical && (key.isUpKey || key.isDownKey)) || 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);
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,
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(
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: !isCompact && canDecrement ? () => startRepeat(_decrement) : null,
onLongPressEnd: isCompact ? null : stopRepeat,
semanticLabel: semanticLabel != null ? a11y.decreaseValue(label: semanticLabel) : a11y.decrease,
compact: isCompact,
),
gap,
Container(
constraints: BoxConstraints(minWidth: isCompact ? 56 : 60),
alignment: .center,
child: Text(
'${widget.value}${widget.suffix ?? ''}',
style: isCompact
? theme.textTheme.titleMedium
: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold),
),
),
gap,
_SpinnerButton(
icon: Symbols.add_rounded,
onPressed: canIncrement ? _increment : null,
onLongPressStart: !isCompact && canIncrement ? () => startRepeat(_increment) : null,
onLongPressEnd: isCompact ? null : stopRepeat,
semanticLabel: semanticLabel != null ? a11y.increaseValue(label: semanticLabel) : a11y.increase,
compact: isCompact,
),
],
),
),
);
}
}
/// Individual +/- button with long-press support.
class _SpinnerButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
final VoidCallback? onLongPressStart;
final VoidCallback? onLongPressEnd;
final String semanticLabel;
final bool compact;
const _SpinnerButton({
required this.icon,
required this.onPressed,
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;
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);
}
}