Files
plezy/lib/widgets/focusable_tab_chip.dart
T

177 lines
5.7 KiB
Dart
Raw Normal View History

2025-12-07 13:57:52 +01:00
import 'package:flutter/material.dart';
2025-12-07 15:00:28 +01:00
import '../focus/focusable_chip_mixin.dart';
2025-12-07 13:57:52 +01:00
import '../focus/input_mode_tracker.dart';
2026-05-16 11:56:12 +02:00
import '../utils/platform_detector.dart';
2025-12-07 15:00:28 +01:00
import 'focus_builders.dart';
2025-12-07 13:57:52 +01:00
/// Horizontally scrollable host for a row of [FocusableTabChip]s.
///
/// App-bar titles and header rows give the strip a bounded width; a plain
/// Row overflows it on narrow windows (visible as the striped overflow
/// indicator). The strip shrink-wraps like `mainAxisSize: min` and scrolls
/// instead. D-pad stays correct: chips center themselves on focus via the
/// chip mixin, so LEFT/RIGHT reaches off-screen tabs.
class TabChipStrip extends StatelessWidget {
final List<Widget> children;
const TabChipStrip({super.key, required this.children});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(mainAxisSize: .min, children: children),
);
}
}
2025-12-07 13:57:52 +01:00
/// A focusable tab chip that shows a color change when focused or selected.
///
/// Used for tab navigation in LibrariesScreen. Handles:
/// - SELECT key to activate the tab
/// - LEFT/RIGHT arrows to switch between tabs
/// - DOWN arrow to navigate to tab content
/// - BACK key to navigate to sidenav
class FocusableTabChip extends StatefulWidget {
final String label;
final bool isSelected;
final VoidCallback onSelect;
/// Optional external focus node for programmatic focus control.
final FocusNode? focusNode;
/// Called when the user presses LEFT from this chip.
/// Should switch to the previous tab.
final VoidCallback? onNavigateLeft;
/// Called when the user presses RIGHT from this chip.
/// Should switch to the next tab.
final VoidCallback? onNavigateRight;
/// Called when the user presses DOWN from this chip.
final VoidCallback? onNavigateDown;
/// Called when the user presses UP from this chip.
final VoidCallback? onNavigateUp;
2025-12-07 13:57:52 +01:00
/// Called when the user presses BACK from this chip.
final VoidCallback? onBack;
/// Called when SELECT key is held (D-pad long press).
final VoidCallback? onLongPress;
2026-04-26 15:11:24 -04:00
/// Optional image to show above the label (e.g. a poster).
/// When provided, the chip lays out vertically with image on top, label below.
final Widget? topImage;
2025-12-07 13:57:52 +01:00
const FocusableTabChip({
super.key,
required this.label,
required this.isSelected,
required this.onSelect,
this.focusNode,
this.onNavigateLeft,
this.onNavigateRight,
this.onNavigateDown,
this.onNavigateUp,
2025-12-07 13:57:52 +01:00
this.onBack,
this.onLongPress,
2026-04-26 15:11:24 -04:00
this.topImage,
2025-12-07 13:57:52 +01:00
});
@override
State<FocusableTabChip> createState() => _FocusableTabChipState();
}
2025-12-15 02:29:03 +01:00
class _FocusableTabChipState extends State<FocusableTabChip> with FocusableChipStateMixin<FocusableTabChip> {
2025-12-07 15:00:28 +01:00
@override
FocusNode? get widgetFocusNode => widget.focusNode;
2025-12-07 13:57:52 +01:00
2025-12-07 15:00:28 +01:00
@override
String get debugLabel => 'tab_chip_${widget.label}';
2025-12-07 13:57:52 +01:00
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
2025-12-12 18:00:34 +01:00
return handleChipKeyEvent(
node,
event,
ChipKeyCallbacks(
onSelect: widget.onSelect,
onLongPress: widget.onLongPress,
2025-12-12 18:00:34 +01:00
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
onNavigateDown: widget.onNavigateDown,
onNavigateUp: widget.onNavigateUp,
2025-12-12 18:00:34 +01:00
onBack: widget.onBack,
),
);
2025-12-07 13:57:52 +01:00
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Only show focus effects during keyboard/d-pad navigation
2025-12-07 15:00:28 +01:00
final showFocus = isFocused && InputModeTracker.isKeyboardMode(context);
2025-12-07 13:57:52 +01:00
// Determine background color based on focus and selection state
// - Selected + Focused: slightly dimmed primary (to show focus distinction)
// - Selected only: primary color
// - Focused only: primary color
// - Neither: surface color
Color backgroundColor;
Color foregroundColor;
if (widget.isSelected && showFocus) {
// Selected + focused: dim the primary color slightly
2025-12-15 02:29:03 +01:00
backgroundColor = Color.lerp(colorScheme.primary, colorScheme.surface, 0.25)!;
2025-12-07 13:57:52 +01:00
foregroundColor = colorScheme.onPrimary;
} else if (widget.isSelected || showFocus) {
// Selected or focused (but not both): full primary
backgroundColor = colorScheme.primary;
foregroundColor = colorScheme.onPrimary;
} else {
// Neither selected nor focused
2026-05-16 11:56:12 +02:00
if (PlatformDetector.isTV()) {
backgroundColor = colorScheme.secondaryContainer.withValues(alpha: 0.38);
foregroundColor = colorScheme.onSecondaryContainer;
} else {
backgroundColor = colorScheme.surfaceContainerHighest;
foregroundColor = colorScheme.onSurfaceVariant;
}
2025-12-07 13:57:52 +01:00
}
final isHighlighted = showFocus || widget.isSelected;
2026-04-26 15:11:24 -04:00
final label = Text(
widget.label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foregroundColor,
fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.normal,
),
);
final hasImage = widget.topImage != null;
2025-12-07 15:00:28 +01:00
return FocusBuilders.buildFocusableChip(
context: context,
focusNode: focusNode,
2025-12-07 13:57:52 +01:00
onKeyEvent: _handleKeyEvent,
2025-12-07 15:00:28 +01:00
onTap: widget.onSelect,
semanticLabel: widget.label,
selected: widget.isSelected,
2026-04-28 04:12:40 +02:00
padding: hasImage ? const EdgeInsets.all(8) : const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
2025-12-07 15:00:28 +01:00
backgroundColor: backgroundColor,
2026-04-26 15:11:24 -04:00
borderRadius: hasImage ? 12 : 20,
child: hasImage
? Column(
2026-06-01 11:06:03 +02:00
mainAxisSize: .min,
2026-04-26 15:11:24 -04:00
children: [
2026-04-28 04:12:40 +02:00
ClipRRect(borderRadius: BorderRadius.circular(6), child: widget.topImage!),
2026-04-26 15:11:24 -04:00
const SizedBox(height: 6),
label,
],
)
: label,
2025-12-07 13:57:52 +01:00
);
}
}