Files
plezy/lib/widgets/side_navigation_rail.dart
T
edde746 551be5bbef feat: dynamic library tabs and shared library navigation
- Replace hardcoded 4-tab system with data-driven visible tab list
- Shared libraries show only Browse + Playlists tabs
- Fix dpad navigation for shared libraries (no hidden tab targets)
- Switch to TickerProviderStateMixin to support tab controller recreation
- Navigate to library screen when tapping shared library sections
- Persist tab selection by name instead of index
2026-03-15 21:36:17 +01:00

838 lines
32 KiB
Dart

import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_memory_tracker.dart';
import '../models/plex_library.dart';
import '../navigation/navigation_tabs.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart';
import '../providers/multi_server_provider.dart';
import '../services/fullscreen_state_manager.dart';
import '../theme/mono_tokens.dart';
import '../i18n/strings.g.dart';
/// Reusable navigation rail item widget that handles focus, selection, and interaction
class NavigationRailItem extends StatelessWidget {
final IconData icon;
final IconData? selectedIcon;
final Widget label;
final bool isSelected;
final bool isFocused;
final bool isCollapsed;
final bool useSimpleLayout;
final VoidCallback onTap;
final FocusNode focusNode;
final bool autofocus;
final BorderRadius borderRadius;
final double iconSize;
/// Called when RIGHT arrow is pressed to navigate to content area.
final VoidCallback? onNavigateRight;
const NavigationRailItem({
super.key,
required this.icon,
this.selectedIcon,
required this.label,
required this.isSelected,
required this.isFocused,
this.isCollapsed = false,
this.useSimpleLayout = false,
required this.onTap,
required this.focusNode,
this.autofocus = false,
this.borderRadius = const BorderRadius.all(Radius.circular(12)),
this.iconSize = 22,
this.onNavigateRight,
});
@override
Widget build(BuildContext context) {
final t = tokens(context);
return Focus(
focusNode: focusNode,
autofocus: autofocus,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey.isSelectKey) {
onTap();
return KeyEventResult.handled;
}
// RIGHT arrow navigates to content area
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
onNavigateRight!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Material(
color: Colors.transparent,
child: InkWell(
canRequestFocus: false,
onTap: onTap,
borderRadius: borderRadius,
child: Container(
decoration: BoxDecoration(
color: () {
if (isSelected && isFocused) return t.text.withValues(alpha: 0.15);
if (isSelected) return t.text.withValues(alpha: 0.1);
if (isFocused) return t.text.withValues(alpha: 0.12);
return null;
}(),
borderRadius: borderRadius,
),
clipBehavior: Clip.hardEdge,
child: UnconstrainedBox(
alignment: Alignment.centerLeft,
constrainedAxis: Axis.vertical,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: SideNavigationRailState.expandedWidth - 24,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17),
child: Row(
children: [
AppIcon(
isSelected && selectedIcon != null ? selectedIcon! : icon,
fill: 1,
size: iconSize,
color: isSelected ? t.text : t.textMuted,
),
const SizedBox(width: 11),
Expanded(
child: () {
if (useSimpleLayout) return label;
final opacity = isCollapsed ? 0.0 : 1.0;
return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label);
}(),
),
],
),
),
),
),
),
),
),
);
}
}
/// Side navigation rail for Desktop and Android TV platforms
class SideNavigationRail extends StatefulWidget {
final int selectedIndex;
final String? selectedLibraryKey;
final bool isOfflineMode;
final bool isSidebarFocused;
final bool alwaysExpanded;
final bool isReconnecting;
final ValueChanged<int> onDestinationSelected;
final ValueChanged<String> onLibrarySelected;
/// Called when RIGHT arrow is pressed to navigate to content without selecting.
final VoidCallback? onNavigateToContent;
/// Called when the user taps the reconnect button in offline mode.
final VoidCallback? onReconnect;
const SideNavigationRail({
super.key,
required this.selectedIndex,
this.selectedLibraryKey,
this.isOfflineMode = false,
this.isSidebarFocused = false,
this.alwaysExpanded = false,
this.isReconnecting = false,
required this.onDestinationSelected,
required this.onLibrarySelected,
this.onNavigateToContent,
this.onReconnect,
});
@override
State<SideNavigationRail> createState() => SideNavigationRailState();
}
class SideNavigationRailState extends State<SideNavigationRail> {
bool _librariesExpanded = true;
// Collapsed/expanded state
bool _isHovered = false;
bool _isTouchExpanded = false;
Timer? _collapseTimer;
static const double collapsedWidth = 80.0;
static const double expandedWidth = 220.0;
static const Duration _collapseDelay = Duration(milliseconds: 150);
// Focus keys for main nav items
static const _kHome = 'home';
static const _kLibraries = 'libraries';
static const _kSearch = 'search';
static const _kDownloads = 'downloads';
static const _kSettings = 'settings';
static const _kReconnect = 'reconnect';
// Unified focus state tracker for all nav items (main + libraries)
late final FocusMemoryTracker _focusTracker;
/// Whether the sidebar should be expanded (always, hover, or focus)
bool get _shouldExpand => widget.alwaysExpanded || _isHovered || _isTouchExpanded || widget.isSidebarFocused;
@override
void initState() {
super.initState();
_focusTracker = FocusMemoryTracker(
onFocusChanged: () {
// ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {});
},
debugLabelPrefix: 'nav',
);
}
@override
void dispose() {
_collapseTimer?.cancel();
_focusTracker.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant SideNavigationRail oldWidget) {
super.didUpdateWidget(oldWidget);
// Auto-collapse after navigation (selection changed)
if (oldWidget.selectedIndex != widget.selectedIndex || oldWidget.selectedLibraryKey != widget.selectedLibraryKey) {
_isTouchExpanded = false;
}
}
void _onHoverEnter() {
_collapseTimer?.cancel();
_isTouchExpanded = false; // Mouse takes over
if (!_isHovered) {
setState(() => _isHovered = true);
}
}
void _onHoverExit() {
_collapseTimer?.cancel();
_collapseTimer = Timer(_collapseDelay, () {
if (mounted && _isHovered) {
setState(() => _isHovered = false);
}
});
}
/// The key of the last focused sidebar item (for pre-capture before focus shifts).
String? get lastFocusedKey => _focusTracker.lastFocusedKey;
/// Focus the last focused nav item, or Home as fallback.
/// If [targetKey] is provided, try it first (used when the caller captured
/// the intended target before a focus-scope switch overwrote it).
void focusActiveItem({String? targetKey}) {
if (targetKey != null) {
final node = _focusTracker.nodeFor(targetKey);
if (node != null) {
node.requestFocus();
return;
}
}
_focusTracker.restoreFocus(fallbackKey: _kHome);
}
/// Build the set of valid focus keys (main nav + current libraries)
Set<String> _buildValidFocusKeys(List<PlexLibrary> libraries) {
return {
_kHome,
_kLibraries,
_kSearch,
_kDownloads,
_kSettings,
_kReconnect,
'liveTv',
...libraries.map((lib) => lib.globalKey),
};
}
/// Ordered list of focusable keys matching visual top-to-bottom order.
List<String> _buildFocusOrder(List<PlexLibrary> visibleLibraries, {required bool hasLiveTv}) {
return [
if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect,
if (!widget.isOfflineMode) ...[
_kHome,
_kLibraries,
if (_librariesExpanded) ...visibleLibraries.map((lib) => lib.globalKey),
if (hasLiveTv) 'liveTv',
_kSearch,
],
_kDownloads,
_kSettings,
];
}
/// Handle D-pad UP/DOWN by explicitly moving focus to the next/previous item.
KeyEventResult _handleVerticalNavigation(FocusNode _, KeyEvent event, List<String> focusOrder) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final isDown = event.logicalKey == LogicalKeyboardKey.arrowDown;
final isUp = event.logicalKey == LogicalKeyboardKey.arrowUp;
if (!isDown && !isUp) return KeyEventResult.ignored;
final currentKey = _focusTracker.lastFocusedKey;
if (currentKey == null) return KeyEventResult.ignored;
final currentIndex = focusOrder.indexOf(currentKey);
if (currentIndex == -1) return KeyEventResult.ignored;
final nextIndex = isDown ? currentIndex + 1 : currentIndex - 1;
if (nextIndex < 0 || nextIndex >= focusOrder.length) return KeyEventResult.handled;
final nextNode = _focusTracker.nodeFor(focusOrder[nextIndex]);
if (nextNode == null) return KeyEventResult.ignored;
nextNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = nextNode.context;
if (ctx != null) {
Scrollable.ensureVisible(ctx,
alignment: 0.5, duration: const Duration(milliseconds: 200));
}
});
return KeyEventResult.handled;
}
/// Collapse the sidebar (resets touch-expand state).
void collapse() {
if (_isTouchExpanded) {
setState(() => _isTouchExpanded = false);
}
}
/// Reload libraries (called when servers change or profile switches)
void reloadLibraries() {
final librariesProvider = context.read<LibrariesProvider>();
librariesProvider.refresh();
}
IconData _getLibraryIcon(String type) {
switch (type.toLowerCase()) {
case 'movie':
return Symbols.movie_rounded;
case 'show':
return Symbols.tv_rounded;
case 'artist':
return Symbols.music_note_rounded;
case 'photo':
return Symbols.photo_rounded;
case 'mixed':
return Symbols.share_rounded;
default:
return Symbols.folder_rounded;
}
}
/// Calculate top padding for macOS traffic lights
double _getTopPadding(BuildContext context) {
double basePadding = MediaQuery.of(context).padding.top + 16;
// On macOS, add extra padding for traffic lights (when not fullscreen)
if (Platform.isMacOS) {
final isFullscreen = FullscreenStateManager().isFullscreen;
if (!isFullscreen) {
// Traffic lights area is approximately 52 pixels high
basePadding = basePadding < 52 ? 52 : basePadding;
}
}
return basePadding;
}
@override
Widget build(BuildContext context) {
final t = tokens(context);
final librariesProvider = context.watch<LibrariesProvider>();
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
// Get libraries from provider and filter visible ones
final allLibraries = librariesProvider.libraries;
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
// Prune stale focus nodes when libraries change
_focusTracker.pruneExcept(_buildValidFocusKeys(allLibraries));
final isCollapsed = !_shouldExpand;
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
final focusOrder = _buildFocusOrder(visibleLibraries, hasLiveTv: hasLiveTv);
// Listen to fullscreen changes for macOS
return ListenableBuilder(
listenable: FullscreenStateManager(),
builder: (context, _) {
return TapRegion(
onTapOutside: (_) {
if (_isTouchExpanded) {
setState(() => _isTouchExpanded = false);
}
},
child: MouseRegion(
onEnter: (_) => _onHoverEnter(),
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: isCollapsed ? () => setState(() => _isTouchExpanded = true) : null,
child: AnimatedContainer(
duration: t.normal,
curve: Curves.easeOutCubic,
width: isCollapsed ? collapsedWidth : expandedWidth,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(color: t.surface),
child: IgnorePointer(
ignoring: isCollapsed,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) =>
_handleVerticalNavigation(node, event, focusOrder),
child: Column(
children: [
// Safe area for status bar and macOS traffic lights
SizedBox(height: _getTopPadding(context)),
// Navigation content
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12),
clipBehavior: Clip.hardEdge,
children: [
// Reconnect button when offline
if (widget.isOfflineMode && widget.onReconnect != null) ...[
_buildReconnectItem(isCollapsed: isCollapsed),
const SizedBox(height: 8),
],
// In online mode, show full navigation
if (!widget.isOfflineMode) ...[
// Home
_buildNavItem(
icon: Symbols.home_rounded,
selectedIcon: Symbols.home_rounded,
label: Translations.of(context).common.home,
isSelected: widget.selectedIndex == 0,
isFocused: _focusTracker.isFocused(_kHome),
onTap: () => widget.onDestinationSelected(0),
focusNode: _focusTracker.get(_kHome),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
// Libraries section
_buildLibrariesSection(visibleLibraries, t, isCollapsed: isCollapsed),
const SizedBox(height: 8),
// Live TV (only if DVR available)
if (context.watch<MultiServerProvider>().hasLiveTv) ...[
_buildNavItem(
icon: Symbols.live_tv_rounded,
selectedIcon: Symbols.live_tv_rounded,
label: Translations.of(context).navigation.liveTv,
isSelected: NavigationTab.isTabAtIndex(
NavigationTabId.liveTv,
widget.selectedIndex,
isOffline: widget.isOfflineMode,
hasLiveTv: true,
),
isFocused: _focusTracker.isFocused('liveTv'),
onTap: () => widget.onDestinationSelected(
NavigationTab.indexFor(
NavigationTabId.liveTv,
isOffline: widget.isOfflineMode,
hasLiveTv: true,
),
),
focusNode: _focusTracker.get('liveTv'),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
// Search
_buildNavItem(
icon: Symbols.search_rounded,
selectedIcon: Symbols.search_rounded,
label: Translations.of(context).common.search,
isSelected: NavigationTab.isTabAtIndex(
NavigationTabId.search,
widget.selectedIndex,
isOffline: widget.isOfflineMode,
hasLiveTv: context.read<MultiServerProvider>().hasLiveTv,
),
isFocused: _focusTracker.isFocused(_kSearch),
onTap: () => widget.onDestinationSelected(
NavigationTab.indexFor(
NavigationTabId.search,
isOffline: widget.isOfflineMode,
hasLiveTv: context.read<MultiServerProvider>().hasLiveTv,
),
),
focusNode: _focusTracker.get(_kSearch),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
// Downloads
Builder(
builder: (context) {
final hasLiveTv = context.read<MultiServerProvider>().hasLiveTv;
return _buildNavItem(
icon: Symbols.download_rounded,
selectedIcon: Symbols.download_rounded,
label: Translations.of(context).navigation.downloads,
isSelected: NavigationTab.isTabAtIndex(
NavigationTabId.downloads,
widget.selectedIndex,
isOffline: widget.isOfflineMode,
hasLiveTv: hasLiveTv,
),
isFocused: _focusTracker.isFocused(_kDownloads),
onTap: () => widget.onDestinationSelected(
NavigationTab.indexFor(
NavigationTabId.downloads,
isOffline: widget.isOfflineMode,
hasLiveTv: hasLiveTv,
),
),
focusNode: _focusTracker.get(_kDownloads),
isCollapsed: isCollapsed,
);
},
),
const SizedBox(height: 8),
// Settings
Builder(
builder: (context) {
final hasLiveTv = context.read<MultiServerProvider>().hasLiveTv;
return _buildNavItem(
icon: Symbols.settings_rounded,
selectedIcon: Symbols.settings_rounded,
label: Translations.of(context).common.settings,
isSelected: NavigationTab.isTabAtIndex(
NavigationTabId.settings,
widget.selectedIndex,
isOffline: widget.isOfflineMode,
hasLiveTv: hasLiveTv,
),
isFocused: _focusTracker.isFocused(_kSettings),
onTap: () => widget.onDestinationSelected(
NavigationTab.indexFor(
NavigationTabId.settings,
isOffline: widget.isOfflineMode,
hasLiveTv: hasLiveTv,
),
),
focusNode: _focusTracker.get(_kSettings),
isCollapsed: isCollapsed,
);
},
),
],
),
),
],
),
),
),
),
),
),
);
},
);
}
Widget _buildNavItem({
required IconData icon,
required IconData selectedIcon,
required String label,
required bool isSelected,
required bool isFocused,
required VoidCallback onTap,
required FocusNode focusNode,
required bool isCollapsed,
bool autofocus = false,
}) {
final t = tokens(context);
return NavigationRailItem(
icon: icon,
selectedIcon: selectedIcon,
label: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? t.text : t.textMuted,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
isSelected: isSelected,
isFocused: isFocused,
isCollapsed: isCollapsed,
onTap: onTap,
focusNode: focusNode,
autofocus: autofocus,
onNavigateRight: widget.onNavigateToContent,
);
}
Widget _buildReconnectItem({required bool isCollapsed}) {
final t = tokens(context);
final isFocused = _focusTracker.isFocused(_kReconnect);
return NavigationRailItem(
icon: widget.isReconnecting ? Symbols.sync_rounded : Symbols.wifi_rounded,
label: widget.isReconnecting
? SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: t.text))
: Text(
Translations.of(context).common.reconnect,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: t.textMuted),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
isSelected: false,
isFocused: isFocused,
isCollapsed: isCollapsed,
// ignore: no-empty-block - no-op tap handler while reconnecting
onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(),
focusNode: _focusTracker.get(_kReconnect),
onNavigateRight: widget.onNavigateToContent,
);
}
Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t, {bool isCollapsed = false}) {
final librariesProvider = context.watch<LibrariesProvider>();
final isLoading = librariesProvider.isLoading;
final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null;
final isLibrariesFocused = _focusTracker.isFocused(_kLibraries);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Libraries header with expand/collapse
Focus(
focusNode: _focusTracker.get(_kLibraries),
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: () {
if (isLibrariesSelected) return t.text.withValues(alpha: 0.1);
if (isLibrariesFocused) return t.text.withValues(alpha: 0.08);
return null;
}(),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
),
clipBehavior: Clip.hardEdge,
child: UnconstrainedBox(
alignment: Alignment.centerLeft,
constrainedAxis: Axis.vertical,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: expandedWidth - 24,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17),
child: Row(
children: [
AppIcon(
Symbols.video_library_rounded,
fill: 1,
size: 22,
color: widget.selectedIndex == 1 ? 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.selectedIndex == 1 ? FontWeight.w600 : FontWeight.w400,
color: widget.selectedIndex == 1 ? 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,
),
),
],
),
),
),
),
),
),
),
),
// Library items with animated height
TweenAnimationBuilder<double>(
tween: Tween(end: (_librariesExpanded && !isCollapsed) ? 1.0 : 0.0),
duration: tokens(context).normal,
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return ClipRect(
child: Align(alignment: Alignment.topCenter, heightFactor: value, child: child),
);
},
child: ExcludeFocus(
excluding: !_librariesExpanded || isCollapsed,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
if (isLoading)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: t.textMuted),
),
),
)
else if (visibleLibraries.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
Translations.of(context).libraries.noLibrariesFound,
style: TextStyle(fontSize: 12, color: t.textMuted),
),
)
else
_buildLibraryItems(visibleLibraries, t),
],
),
),
),
],
);
}
/// Get set of library names that appear more than once (not globally unique)
Set<String> _getNonUniqueLibraryNames(List<PlexLibrary> libraries) {
final nameCounts = <String, int>{};
for (final lib in libraries) {
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
}
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
}
Widget _buildLibraryItems(List<PlexLibrary> visibleLibraries, dynamic t) {
// Find which library names are not unique
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: visibleLibraries.map((library) {
final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null;
return _buildLibraryItem(library, t, showServerName: showServerName);
}).toList(),
);
}
Widget _buildLibraryItem(PlexLibrary library, dynamic t, {bool showServerName = false}) {
final isSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == library.globalKey;
final isFocused = _focusTracker.isFocused(library.globalKey);
final focusNode = _focusTracker.get(library.globalKey);
return Padding(
padding: const EdgeInsets.only(left: 12),
child: NavigationRailItem(
icon: _getLibraryIcon(library.type),
selectedIcon: _getLibraryIcon(library.type),
label: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
library.title,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? t.text : t.textMuted,
),
overflow: TextOverflow.ellipsis,
),
if (showServerName)
Text(
library.serverName!,
style: TextStyle(fontSize: 9, color: t.textMuted.withValues(alpha: 0.4)),
overflow: TextOverflow.ellipsis,
),
],
),
isSelected: isSelected,
isFocused: isFocused,
useSimpleLayout: true,
onTap: () => widget.onLibrarySelected(library.globalKey),
focusNode: focusNode,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
iconSize: 18,
onNavigateRight: widget.onNavigateToContent,
),
);
}
}