Files
plezy/lib/widgets/tv_number_spinner.dart
T

242 lines
7.2 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';
/// 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.
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;
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;
const TvNumberSpinner({
super.key,
required this.value,
required this.min,
required this.max,
required this.onChanged,
this.step = 1,
this.suffix,
this.autofocus = false,
this.onConfirm,
this.onCancel,
});
@override
State<TvNumberSpinner> createState() => _TvNumberSpinnerState();
}
class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<TvNumberSpinner> {
late FocusNode _focusNode;
bool _isFocused = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'TvNumberSpinner');
}
@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;
if (widget.onCancel != null) {
final backResult = handleBackKeyAction(event, widget.onCancel!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
}
if (event is KeyDownEvent) {
if (key.isSelectKey && widget.onConfirm != null) {
widget.onConfirm!();
return KeyEventResult.handled;
}
if (key.isUpKey || key.isRightKey) {
startRepeat(_increment);
return KeyEventResult.handled;
} else if (key.isDownKey || key.isLeftKey) {
startRepeat(_decrement);
return KeyEventResult.handled;
}
} else if (event is KeyUpEvent) {
if (key.isUpKey || key.isRightKey || key.isDownKey || 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(
mainAxisSize: .min,
mainAxisAlignment: .center,
children: [
_SpinnerButton(
icon: Symbols.remove_rounded,
onPressed: canDecrement ? _decrement : null,
onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null,
onLongPressEnd: stopRepeat,
semanticLabel: Translations.of(context).accessibility.decrease,
),
const SizedBox(width: 16),
Container(
constraints: const BoxConstraints(minWidth: 60),
alignment: .center,
child: Text(
widget.suffix != null ? '${widget.value}${widget.suffix}' : '${widget.value}',
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold),
),
),
const SizedBox(width: 16),
_SpinnerButton(
icon: Symbols.add_rounded,
onPressed: canIncrement ? _increment : null,
onLongPressStart: canIncrement ? () => startRepeat(_increment) : null,
onLongPressEnd: stopRepeat,
semanticLabel: Translations.of(context).accessibility.increase,
),
],
),
),
);
}
}
/// 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;
const _SpinnerButton({
required this.icon,
required this.onPressed,
this.onLongPressStart,
this.onLongPressEnd,
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: 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),
),
),
),
),
),
),
);
}
}