From efcbccacf04f8b932e7e26715855b989f0d62171 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:45:59 +0100 Subject: [PATCH] feat: collapsible sidenav --- lib/focus/focusable_wrapper.dart | 2 + lib/screens/main_screen.dart | 56 ++-- lib/widgets/focusable_media_card.dart | 6 + lib/widgets/side_navigation_rail.dart | 357 ++++++++++++++++---------- 4 files changed, 269 insertions(+), 152 deletions(-) diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 1fce4f9f..6a667330 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -282,6 +282,7 @@ class _FocusableWrapperState extends State with SingleTickerPr _longPressTimer = Timer(widget.longPressDuration, () { // Long press detected if (mounted) { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); widget.onLongPress?.call(); } }); @@ -317,6 +318,7 @@ class _FocusableWrapperState extends State with SingleTickerPr // Context menu key if (key.isContextMenuKey) { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); widget.onLongPress?.call(); return KeyEventResult.handled; } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 3b830041..5f27d1da 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -582,6 +582,9 @@ class _MainScreenState extends State with RouteAware, WindowListener final useSideNav = PlatformDetector.shouldUseSideNavigation(context); if (useSideNav) { + // Collapsed sidebar width for content padding + const collapsedSidebarWidth = SideNavigationRailState.collapsedWidth; + return PopScope( canPop: false, // Prevent system back from popping on Android TV onPopInvokedWithResult: (didPop, result) { @@ -596,31 +599,42 @@ class _MainScreenState extends State with RouteAware, WindowListener focusContent: _focusContent, isSidebarFocused: _isSidebarFocused, child: SideNavigationScope( - child: Row( + child: Stack( children: [ - FocusScope( - node: _sidebarFocusScope, - child: SideNavigationRail( - key: _sideNavKey, - selectedIndex: _currentIndex, - selectedLibraryKey: _selectedLibraryGlobalKey, - isOfflineMode: _isOffline, - onDestinationSelected: (index) { - _selectTab(index); - _focusContent(); - }, - onLibrarySelected: (key) { - _selectLibrary(key); - _focusContent(); - }, + // Content with fixed left padding for collapsed sidebar + Positioned.fill( + child: Padding( + padding: const EdgeInsets.only(left: collapsedSidebarWidth), + child: FocusScope( + node: _contentFocusScope, + // No autofocus - we control focus programmatically to prevent + // autofocus from stealing focus back after setState() rebuilds + child: IndexedStack(index: _currentIndex, children: _screens), + ), ), ), - Expanded( + // Sidebar overlays content when expanded + Positioned( + top: 0, + bottom: 0, + left: 0, child: FocusScope( - node: _contentFocusScope, - // No autofocus - we control focus programmatically to prevent - // autofocus from stealing focus back after setState() rebuilds - child: IndexedStack(index: _currentIndex, children: _screens), + node: _sidebarFocusScope, + child: SideNavigationRail( + key: _sideNavKey, + selectedIndex: _currentIndex, + selectedLibraryKey: _selectedLibraryGlobalKey, + isOfflineMode: _isOffline, + isSidebarFocused: _isSidebarFocused, + onDestinationSelected: (index) { + _selectTab(index); + _focusContent(); + }, + onLibrarySelected: (key) { + _selectLibrary(key); + _focusContent(); + }, + ), ), ), ], diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 6983c943..4e16ad90 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -36,6 +36,10 @@ class FocusableMediaCard extends StatefulWidget { /// Used to navigate from tab content to tab bar. final VoidCallback? onBack; + /// Called when focus changes. + /// Used to track which grid item was last focused. + final ValueChanged? onFocusChange; + const FocusableMediaCard({ super.key, required this.item, @@ -51,6 +55,7 @@ class FocusableMediaCard extends StatefulWidget { this.focusNode, this.onNavigateUp, this.onBack, + this.onFocusChange, }); @override @@ -69,6 +74,7 @@ class _FocusableMediaCardState extends State { onLongPress: () => _mediaCardKey.currentState?.showContextMenu(), onNavigateUp: widget.onNavigateUp, onBack: widget.onBack, + onFocusChange: widget.onFocusChange, enableLongPress: true, useComfortableZone: !PlatformDetector.isTV(), // Always center on TV scrollAlignment: 0.5, diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 0f454bf8..4ee009ff 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; @@ -76,10 +77,11 @@ class NavigationRailItem extends StatelessWidget { 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 EdgeInsets padding; final BorderRadius borderRadius; final double iconSize; @@ -90,10 +92,11 @@ class NavigationRailItem extends StatelessWidget { 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.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 12), this.borderRadius = const BorderRadius.all(Radius.circular(12)), this.iconSize = 22, }); @@ -119,7 +122,6 @@ class NavigationRailItem extends StatelessWidget { onTap: onTap, borderRadius: borderRadius, child: Container( - padding: padding, decoration: BoxDecoration( color: isSelected && isFocused ? t.text.withValues(alpha: 0.15) // Selected + focused @@ -130,17 +132,33 @@ class NavigationRailItem extends StatelessWidget { : null, borderRadius: borderRadius, ), - child: Row( - children: [ - AppIcon( - isSelected && selectedIcon != null ? selectedIcon! : icon, - fill: 1, - size: iconSize, - color: isSelected ? t.text : t.textMuted, + 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: useSimpleLayout + ? label + : AnimatedOpacity(opacity: isCollapsed ? 0.0 : 1.0, duration: t.fast, child: label), + ), + ], + ), ), - const SizedBox(width: 12), - Expanded(child: label), - ], + ), ), ), ), @@ -154,6 +172,7 @@ class SideNavigationRail extends StatefulWidget { final int selectedIndex; final String? selectedLibraryKey; final bool isOfflineMode; + final bool isSidebarFocused; final ValueChanged onDestinationSelected; final ValueChanged onLibrarySelected; @@ -162,6 +181,7 @@ class SideNavigationRail extends StatefulWidget { required this.selectedIndex, this.selectedLibraryKey, this.isOfflineMode = false, + this.isSidebarFocused = false, required this.onDestinationSelected, required this.onLibrarySelected, }); @@ -175,6 +195,13 @@ class SideNavigationRailState extends State { List _libraries = []; bool _isLoadingLibraries = true; + // Collapsed/expanded state + bool _isHovered = 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'; @@ -185,6 +212,9 @@ class SideNavigationRailState extends State { // Unified focus state tracker for all nav items (main + libraries) late final _FocusStateTracker _focusTracker; + /// Whether the sidebar should be expanded (hover or focus) + bool get _shouldExpand => _isHovered || widget.isSidebarFocused; + @override void initState() { super.initState(); @@ -194,12 +224,38 @@ class SideNavigationRailState extends State { _loadLibraries(); } + @override + void didUpdateWidget(SideNavigationRail oldWidget) { + super.didUpdateWidget(oldWidget); + // Trigger rebuild when focus state changes + if (oldWidget.isSidebarFocused != widget.isSidebarFocused) { + setState(() {}); + } + } + @override void dispose() { + _collapseTimer?.cancel(); _focusTracker.dispose(); super.dispose(); } + void _onHoverEnter() { + _collapseTimer?.cancel(); + if (!_isHovered) { + setState(() => _isHovered = true); + } + } + + void _onHoverExit() { + _collapseTimer?.cancel(); + _collapseTimer = Timer(_collapseDelay, () { + if (mounted && _isHovered) { + setState(() => _isHovered = false); + } + }); + } + /// Focus the currently selected nav item void focusActiveItem() { if (widget.selectedLibraryKey != null) { @@ -319,96 +375,110 @@ class SideNavigationRailState extends State { // Filter visible libraries final visibleLibraries = _libraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); + final isCollapsed = !_shouldExpand; + // Listen to fullscreen changes for macOS return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { - return Container( - width: 220, - color: t.surface, - child: Column( - children: [ - // Safe area for status bar and macOS traffic lights - SizedBox(height: _getTopPadding(context)), + return MouseRegion( + onEnter: (_) => _onHoverEnter(), + onExit: (_) => _onHoverExit(), + child: AnimatedContainer( + duration: t.normal, + curve: Curves.easeOutCubic, + width: isCollapsed ? collapsedWidth : _expandedWidth, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration(color: t.surface), + 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), - children: [ - // In offline mode, only show Downloads and Settings - if (!widget.isOfflineMode) ...[ - // Home + // Navigation content + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 12), + clipBehavior: Clip.hardEdge, + children: [ + // In offline mode, only show Downloads and Settings + if (!widget.isOfflineMode) ...[ + // Home + _buildNavItem( + icon: Symbols.home_rounded, + selectedIcon: Symbols.home_rounded, + label: Translations.of(context).navigation.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), + + // Search + _buildNavItem( + icon: Symbols.search_rounded, + selectedIcon: Symbols.search_rounded, + label: Translations.of(context).navigation.search, + isSelected: widget.selectedIndex == 2, + isFocused: _focusTracker.isFocused(_kSearch), + onTap: () => widget.onDestinationSelected(2), + focusNode: _focusTracker.get(_kSearch), + isCollapsed: isCollapsed, + ), + + const SizedBox(height: 8), + ], + + // Downloads _buildNavItem( - icon: Symbols.home_rounded, - selectedIcon: Symbols.home_rounded, - label: Translations.of(context).navigation.home, - isSelected: widget.selectedIndex == 0, - isFocused: _focusTracker.isFocused(_kHome), - onTap: () => widget.onDestinationSelected(0), - focusNode: _focusTracker.get(_kHome), + icon: Symbols.download_rounded, + selectedIcon: Symbols.download_rounded, + label: Translations.of(context).navigation.downloads, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.downloads, + widget.selectedIndex, + isOffline: widget.isOfflineMode, + ), + isFocused: _focusTracker.isFocused(_kDownloads), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor(NavigationTabId.downloads, isOffline: widget.isOfflineMode), + ), + focusNode: _focusTracker.get(_kDownloads), + isCollapsed: isCollapsed, ), const SizedBox(height: 8), - // Libraries section - _buildLibrariesSection(visibleLibraries, t), - - const SizedBox(height: 8), - - // Search + // Settings _buildNavItem( - icon: Symbols.search_rounded, - selectedIcon: Symbols.search_rounded, - label: Translations.of(context).navigation.search, - isSelected: widget.selectedIndex == 2, - isFocused: _focusTracker.isFocused(_kSearch), - onTap: () => widget.onDestinationSelected(2), - focusNode: _focusTracker.get(_kSearch), + icon: Symbols.settings_rounded, + selectedIcon: Symbols.settings_rounded, + label: Translations.of(context).navigation.settings, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.settings, + widget.selectedIndex, + isOffline: widget.isOfflineMode, + ), + isFocused: _focusTracker.isFocused(_kSettings), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor(NavigationTabId.settings, isOffline: widget.isOfflineMode), + ), + focusNode: _focusTracker.get(_kSettings), + isCollapsed: isCollapsed, ), - - const SizedBox(height: 8), ], - - // Downloads - _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, - ), - isFocused: _focusTracker.isFocused(_kDownloads), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor(NavigationTabId.downloads, isOffline: widget.isOfflineMode), - ), - focusNode: _focusTracker.get(_kDownloads), - ), - - const SizedBox(height: 8), - - // Settings - _buildNavItem( - icon: Symbols.settings_rounded, - selectedIcon: Symbols.settings_rounded, - label: Translations.of(context).navigation.settings, - isSelected: NavigationTab.isTabAtIndex( - NavigationTabId.settings, - widget.selectedIndex, - isOffline: widget.isOfflineMode, - ), - isFocused: _focusTracker.isFocused(_kSettings), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor(NavigationTabId.settings, isOffline: widget.isOfflineMode), - ), - focusNode: _focusTracker.get(_kSettings), - ), - ], + ), ), - ), - ], + ], + ), ), ); }, @@ -423,6 +493,7 @@ class SideNavigationRailState extends State { required bool isFocused, required VoidCallback onTap, required FocusNode focusNode, + required bool isCollapsed, bool autofocus = false, }) { final t = tokens(context); @@ -437,16 +508,19 @@ class SideNavigationRailState extends State { 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, ); } - Widget _buildLibrariesSection(List visibleLibraries, dynamic t) { + Widget _buildLibrariesSection(List visibleLibraries, dynamic t, {bool isCollapsed = false}) { final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null; final isLibrariesFocused = _focusTracker.isFocused(_kLibraries); @@ -476,7 +550,6 @@ class SideNavigationRailState extends State { }, borderRadius: BorderRadius.circular(tokens(context).radiusMd), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: isLibrariesSelected ? t.text.withValues(alpha: 0.1) @@ -485,40 +558,61 @@ class SideNavigationRailState extends State { : null, borderRadius: BorderRadius.circular(tokens(context).radiusMd), ), - child: Row( - children: [ - AppIcon( - widget.selectedIndex == 1 ? Symbols.video_library_rounded : Symbols.video_library_rounded, - fill: 1, - size: 22, - color: widget.selectedIndex == 1 ? t.text : t.textMuted, - ), - const SizedBox(width: 12), - Expanded( - 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, - ), + 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, + ), + ), + ], ), ), - AppIcon( - _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, - fill: 1, - size: 20, - color: t.textMuted, - ), - ], + ), ), ), ), ), ), - // Library items - if (_librariesExpanded) + // Library items (hidden when collapsed to avoid clipped partial items) + if (_librariesExpanded && !isCollapsed) ...[ + const SizedBox(height: 4), _isLoadingLibraries ? Padding( padding: const EdgeInsets.all(16), @@ -539,6 +633,7 @@ class SideNavigationRailState extends State { ), ) : _buildLibraryItems(visibleLibraries, t), + ], ], ); } @@ -570,14 +665,14 @@ class SideNavigationRailState extends State { final isFocused = _focusTracker.isFocused(library.globalKey); final focusNode = _focusTracker.get(library.globalKey); - return NavigationRailItem( - icon: _getLibraryIcon(library.type), - selectedIcon: _getLibraryIcon(library.type), - label: SizedBox( - height: 32, // Fixed height for consistent item sizing - child: Column( + return Padding( + padding: const EdgeInsets.only(left: 12), + child: NavigationRailItem( + icon: _getLibraryIcon(library.type), + selectedIcon: _getLibraryIcon(library.type), + label: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Text( library.title, @@ -596,14 +691,14 @@ class SideNavigationRailState extends State { ), ], ), + isSelected: isSelected, + isFocused: isFocused, + useSimpleLayout: true, + onTap: () => widget.onLibrarySelected(library.globalKey), + focusNode: focusNode, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + iconSize: 18, ), - isSelected: isSelected, - isFocused: isFocused, - onTap: () => widget.onLibrarySelected(library.globalKey), - focusNode: focusNode, - padding: const EdgeInsets.only(left: 28, right: 12, top: 10, bottom: 10), - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - iconSize: 18, ); } }