Files
plezy/lib/widgets/hub_section.dart
T

312 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/plex_hub.dart';
import '../screens/hub_detail_screen.dart';
import 'media_card.dart';
import 'horizontal_scroll_with_arrows.dart';
import 'hub_navigation_controller.dart';
import '../i18n/strings.g.dart';
import 'focus/focus_indicator.dart';
import '../utils/keyboard_utils.dart';
/// Shared hub section widget used in both discover and library screens
/// Displays a hub title with icon and a horizontal scrollable list of items
class HubSection extends StatefulWidget {
final PlexHub hub;
final IconData icon;
final void Function(String)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool isInContinueWatching;
/// Order for navigation (lower = higher on screen). Default is 1000 for dynamic hubs.
final int navigationOrder;
const HubSection({
super.key,
required this.hub,
required this.icon,
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isInContinueWatching = false,
this.navigationOrder = 1000,
});
@override
State<HubSection> createState() => _HubSectionState();
}
class _HubSectionState extends State<HubSection> {
late final FocusNode _headerFocusNode;
bool _headerIsFocused = false;
HubNavigationController? _controller;
/// Focus nodes for each item in the hub
List<FocusNode> _itemFocusNodes = [];
String? _registeredHubId;
int? _registeredItemCount;
int? _registeredOrder;
String get _hubId => widget.hub.hubIdentifier ?? widget.hub.title;
@override
void initState() {
super.initState();
_headerFocusNode = FocusNode();
_headerFocusNode.addListener(_handleHeaderFocusChange);
_createItemFocusNodes();
}
void _createItemFocusNodes() {
// Create focus nodes for each item
_itemFocusNodes = List.generate(
widget.hub.items.length,
(index) => FocusNode(debugLabel: 'HubItem_${_hubId}_$index'),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_registerWithController();
}
@override
void didUpdateWidget(HubSection oldWidget) {
super.didUpdateWidget(oldWidget);
// If items changed, recreate focus nodes
if (widget.hub.items.length != _itemFocusNodes.length) {
_disposeItemFocusNodes();
_createItemFocusNodes();
}
_registerWithController();
}
void _unregisterFromController() {
if (_controller != null && _registeredHubId != null) {
_controller!.unregister(_registeredHubId!);
}
_registeredHubId = null;
_registeredItemCount = null;
_registeredOrder = null;
}
void _registerWithController() {
final controller = HubNavigationScope.maybeOf(context);
final hubId = _hubId;
final itemCount = widget.hub.items.length;
final order = widget.navigationOrder;
if (controller != _controller) {
_unregisterFromController();
_controller = controller;
}
if (controller == null) return;
final registrationChanged =
_registeredHubId != hubId ||
_registeredItemCount != itemCount ||
_registeredOrder != order;
if (registrationChanged) {
_unregisterFromController();
controller.register(
HubSectionRegistration(
hubId: hubId,
itemCount: itemCount,
focusItem: _focusItem,
order: order,
),
);
_registeredHubId = hubId;
_registeredItemCount = itemCount;
_registeredOrder = order;
}
}
void _focusItem(int index) {
if (index >= 0 && index < _itemFocusNodes.length) {
_itemFocusNodes[index].requestFocus();
}
}
void _disposeItemFocusNodes() {
for (final node in _itemFocusNodes) {
node.dispose();
}
_itemFocusNodes = [];
}
@override
void dispose() {
_unregisterFromController();
_headerFocusNode.removeListener(_handleHeaderFocusChange);
_headerFocusNode.dispose();
_disposeItemFocusNodes();
super.dispose();
}
void _handleHeaderFocusChange() {
if (_headerIsFocused != _headerFocusNode.hasFocus) {
setState(() {
_headerIsFocused = _headerFocusNode.hasFocus;
});
}
}
void _navigateToHubDetail() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)),
);
}
KeyEventResult _handleHeaderKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && widget.hub.more) {
if (isKeyboardActivationKey(event.logicalKey)) {
_navigateToHubDetail();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return FocusTraversalGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Hub header
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Focus(
focusNode: _headerFocusNode,
onKeyEvent: _handleHeaderKeyEvent,
canRequestFocus: widget.hub.more, // Only focusable if has "more"
child: FocusIndicator(
isFocused: _headerIsFocused && widget.hub.more,
borderRadius: 8,
child: InkWell(
onTap: widget.hub.more ? _navigateToHubDetail : null,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.hub.title,
style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
if (widget.hub.more) ...[
const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 20),
],
],
),
),
),
),
),
),
// Hub items (horizontal scroll)
if (widget.hub.items.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
// Responsive card width based on screen size
final screenWidth = constraints.maxWidth;
final cardWidth = screenWidth > 1600
? 220.0
: screenWidth > 1200
? 200.0
: screenWidth > 800
? 190.0
: 160.0;
// MediaCard has 8px padding on all sides (16px total horizontally)
// So actual poster width is cardWidth - 16
final posterWidth = cardWidth - 16;
// 2:3 poster aspect ratio (height is 1.5x width)
final posterHeight = posterWidth * 1.5;
// Container height = poster + padding + spacing + text + focus indicator headroom
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
// + 10px extra for focus indicator border (3px) and scale effect (1.02x)
final containerHeight = posterHeight + 46 + 10;
return SizedBox(
height: containerHeight,
child: ClipRect(
clipBehavior: Clip.none,
child: HorizontalScrollWithArrows(
builder: (scrollController) => FocusTraversalGroup(
child: ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior:
Clip.none, // Allow focus indicator to overflow
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 5,
),
itemCount: widget.hub.items.length,
itemBuilder: (context, index) {
final item = widget.hub.items[index];
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2,
),
child: MediaCard(
key: Key(item.ratingKey),
item: item,
width: cardWidth,
height: posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching:
widget.onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching:
widget.isInContinueWatching,
focusNode: _itemFocusNodes.length > index
? _itemFocusNodes[index]
: null,
hubId: _hubId,
itemIndex: index,
),
);
},
),
),
),
),
);
},
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
t.messages.noItemsAvailable,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
),
],
),
);
}
}