From 4a1111f435d3fe3f6e4169ebbefb9e21eaa95064 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:35:23 +0200 Subject: [PATCH] feat(navigation): restyle sidebar as M3 Expressive navigation rail The custom sidebar read as an ad-hoc widget: collapsed rows were the expanded layout clipped to the strip, the libraries tree used a staircase of indents and mixed row heights, and hover expansion pushed the whole content area sideways. Rebuild the rail on M3 Expressive geometry: collapsed destinations are icon-only 56x32 pills centered in an 80px strip (48px icon-only on TV), expanded rows are fixed-height destinations with a full-width stadium indicator, and the two layouts crossfade/lerp during the width morph instead of clipping. The libraries section is flattened into full-width destination rows under a chevron header at standard destination metrics, hover/touch expansion overlays content as a rounded, shadowed panel over a dimmed scrim instead of pushing it, and rail width, item morphs, content translate, and bleed all share one duration/curve. --- lib/navigation/main_screen_scope.dart | 12 +- lib/screens/main_screen.dart | 32 +- lib/widgets/side_navigation_rail.dart | 647 ++++++++++++-------- test/widgets/side_navigation_rail_test.dart | 58 +- 4 files changed, 474 insertions(+), 275 deletions(-) diff --git a/lib/navigation/main_screen_scope.dart b/lib/navigation/main_screen_scope.dart index f69bfb71b..786607168 100644 --- a/lib/navigation/main_screen_scope.dart +++ b/lib/navigation/main_screen_scope.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../widgets/side_navigation_rail.dart'; + /// Dependency aspects for [MainScreenFocusScope]. /// /// The scope is an [InheritedModel] so the per-sidebar-flip values (`offset`) @@ -119,9 +121,9 @@ class MainScreenFocusScope extends InheritedModel { /// content box slides during sidebar expansion. /// /// The duration/curve MUST mirror the content-slide tween in MainScreen's -/// `_buildContent`: pinning works because the two tweens retarget on the same -/// frame and track each other exactly, so the animated `-bleed` cancels the -/// content translate tick for tick. +/// `_buildContent` and the rail's own width animation: pinning works because +/// the tweens retarget on the same frame and track each other exactly, so the +/// animated `-bleed` cancels the content translate tick for tick. class SideNavigationBleedBuilder extends StatelessWidget { final double targetBleed; final Widget? child; @@ -133,8 +135,8 @@ class SideNavigationBleedBuilder extends StatelessWidget { Widget build(BuildContext context) { return TweenAnimationBuilder( tween: Tween(end: targetBleed), - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, + duration: SideNavigationRailState.expandDuration, + curve: SideNavigationRailState.expandCurve, builder: builder, child: child, ); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index a5807cfe5..f0a4a0fb9 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -296,6 +296,9 @@ class _MainScreenState extends State final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); bool _isSidebarFocused = false; + // Hover/touch rail expansion is an M3E modal overlay: the rail draws over + // the content, so this only drives the scrim behind it — never the + // content offset. bool _isSidebarInteractionExpanded = false; bool _isOverlaySheetOpen = false; @@ -1214,11 +1217,6 @@ class _MainScreenState extends State _updateTvosMenuPassthrough(); } - void _handleSidebarInteractionExpandedChanged(bool expanded) { - if (_isSidebarInteractionExpanded == expanded) return; - setState(() => _isSidebarInteractionExpanded = expanded); - } - void _handleOverlaySheetOpenChanged(bool open) { if (_isOverlaySheetOpen == open) return; _isOverlaySheetOpen = open; @@ -1226,7 +1224,7 @@ class _MainScreenState extends State } double _sideNavigationWidth(BuildContext context, {required bool alwaysExpanded}) { - final isExpanded = alwaysExpanded || _isSidebarFocused || _isSidebarInteractionExpanded; + final isExpanded = alwaysExpanded || _isSidebarFocused; return isExpanded ? SideNavigationRailState.expandedWidth : SideNavigationRailState.collapsedWidthForContext(context); @@ -1771,8 +1769,8 @@ class _MainScreenState extends State return _handleBackKey(event); }, child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, + duration: SideNavigationRailState.expandDuration, + curve: SideNavigationRailState.expandCurve, tween: Tween(end: targetContentOffset), child: FocusScope( node: _contentFocusScope, @@ -1821,6 +1819,19 @@ class _MainScreenState extends State child: contentChild!, ), ), + // Scrim behind the modal (hover/touch) rail + // overlay; purely visual so content stays + // interactive and hover-out still collapses. + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _isSidebarInteractionExpanded ? 1.0 : 0.0, + duration: SideNavigationRailState.expandDuration, + curve: SideNavigationRailState.expandCurve, + child: const ColoredBox(color: Color(0x66000000)), + ), + ), + ), Positioned( top: 0, bottom: 0, @@ -1835,7 +1846,6 @@ class _MainScreenState extends State isSidebarFocused: _isSidebarFocused, alwaysExpanded: alwaysExpanded, isReconnecting: _isReconnecting, - onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged, onDestinationSelected: (tab) { final restorePreviousFocus = tab == _currentTab; _selectTab(tab); @@ -1846,6 +1856,10 @@ class _MainScreenState extends State _focusContent(restorePreviousFocus: false); }, onNavigateToContent: _focusContent, + onInteractionExpandedChanged: (expanded) { + if (_isSidebarInteractionExpanded == expanded) return; + setState(() => _isSidebarInteractionExpanded = expanded); + }, onReconnect: _triggerReconnect, ), ), diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 6f961e47f..1cb574f2c 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' show lerpDouble; import '../media/ids.dart'; import 'dart:io' show Platform; @@ -68,8 +69,23 @@ KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelec return KeyEventResult.ignored; } -/// Reusable navigation rail item widget that handles focus, selection, and interaction +/// Reusable navigation rail item widget that handles focus, selection, and interaction. +/// +/// Renders the two M3 Expressive destination layouts and morphs between them: +/// collapsed is an icon-only 56x32 pill indicator centered in the strip +/// (announced via [collapsedLabel]); expanded is a full-width stadium row +/// with leading icon and [label]. Items that never render collapsed +/// (library rows) leave [collapsedLabel] null and always use the expanded +/// layout. class NavigationRailItem extends StatelessWidget { + /// M3E collapsed-rail active indicator geometry. + static const double collapsedIndicatorWidth = 56; + static const double collapsedIndicatorHeight = 32; + + /// Fixed collapsed item height so the collapse morph interpolates between + /// known extents (32px pill + breathing room). + static const double collapsedItemHeight = 40; + final IconData icon; final IconData? selectedIcon; @@ -78,17 +94,30 @@ class NavigationRailItem extends StatelessWidget { final Widget? iconWidget; final Widget label; + /// Semantic label for the icon-only collapsed pill. Null means the item + /// has no collapsed representation and always lays out expanded. + final String? collapsedLabel; + /// Widget rendered after the [label] (e.g. a section header's chevron). + /// Expanded layout only. final Widget? trailing; final bool isSelected; final bool isCollapsed; - final bool useSimpleLayout; final VoidCallback onTap; final FocusNode focusNode; final bool autofocus; - final BorderRadius borderRadius; final double iconSize; + + /// Horizontal content padding of the expanded row. final double horizontalPadding; + + /// Fixed content width of the expanded layout, so the collapse morph and + /// the rail width animation stay in lockstep. + final double expandedContentWidth; + + /// Expanded row height: fixed for collapsible items, a minimum for + /// expanded-only items (whose labels may add a second line). + final double expandedHeight; final bool suppressSelectedBackground; /// Background tint while keyboard-focused, and its stronger variant used @@ -105,31 +134,156 @@ class NavigationRailItem extends StatelessWidget { this.selectedIcon, this.iconWidget, required this.label, + this.collapsedLabel, this.trailing, required this.isSelected, 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.horizontalPadding = 17, + this.expandedContentWidth = SideNavigationRailState.expandedWidth - 24, + this.expandedHeight = 48, this.suppressSelectedBackground = false, this.focusAlpha = 0.12, this.selectedFocusAlpha = 0.15, this.onNavigateRight, }); + Color? _indicatorColor(MonoTokens t, {required bool focused, required bool collapsedLayout}) { + // The collapsed TV rail is a transparent overlay strip; a persistent + // active pill over artwork is noise there, so it shows focus only. + final showSelected = isSelected && !suppressSelectedBackground && !(collapsedLayout && PlatformDetector.isTV()); + if (focused) return t.text.withValues(alpha: showSelected ? selectedFocusAlpha : focusAlpha); + if (showSelected) return t.text.withValues(alpha: 0.1); + return null; + } + + Widget _leadingIcon(MonoTokens t) => + iconWidget ?? + AppIcon( + isSelected && selectedIcon != null ? selectedIcon! : icon, + fill: 1, + size: iconSize, + color: isSelected ? t.text : t.textMuted, + ); + + static double _collapsedItemWidth(BuildContext context) => + SideNavigationRailState.collapsedWidthForContext(context) - + 2 * SideNavigationRailState.horizontalPaddingForContext(context, isCollapsed: true); + + /// M3E collapsed destination: an icon-only pill indicator. + Widget _buildCollapsedLayout(BuildContext context, MonoTokens t, {required bool focused}) { + final width = _collapsedItemWidth(context); + return SizedBox( + width: width, + height: collapsedItemHeight, + child: Center( + child: Semantics( + label: collapsedLabel, + child: Container( + width: width < collapsedIndicatorWidth ? width : collapsedIndicatorWidth, + height: collapsedIndicatorHeight, + alignment: .center, + decoration: BoxDecoration( + color: _indicatorColor(t, focused: focused, collapsedLayout: true), + borderRadius: BorderRadius.circular(MonoTokens.radiusFull), + ), + child: _leadingIcon(t), + ), + ), + ), + ); + } + + /// M3E expanded destination: full-width stadium indicator behind the row. + Widget _buildExpandedLayout(MonoTokens t, {required bool focused, required bool fixedHeight}) { + return Container( + constraints: fixedHeight ? null : BoxConstraints(minHeight: expandedHeight), + alignment: .centerLeft, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: fixedHeight ? 0 : 8), + decoration: BoxDecoration( + color: _indicatorColor(t, focused: focused, collapsedLayout: false), + borderRadius: BorderRadius.circular(MonoTokens.radiusFull), + ), + child: Row( + children: [ + _leadingIcon(t), + const SizedBox(width: 11), + Expanded(child: label), + ?trailing, + ], + ), + ); + } + + /// At the endpoints only the active layout exists in the tree; mid-morph + /// both are stacked and crossfaded inside a box that lerps between their + /// fixed extents, tracking the rail's own width animation tick for tick. + Widget _buildMorphingLayouts(BuildContext context, MonoTokens t, {required bool focused, required double expansion}) { + if (expansion <= 0) return _buildCollapsedLayout(context, t, focused: focused); + if (expansion >= 1) { + return SizedBox( + width: expandedContentWidth, + height: expandedHeight, + child: _buildExpandedLayout(t, focused: focused, fixedHeight: true), + ); + } + final collapsedWidth = _collapsedItemWidth(context); + const collapsedHeight = collapsedItemHeight; + // Both children keep their natural fixed extents and are only clipped by + // the lerping box, so neither layout is ever squeezed mid-morph. + return SizedBox( + width: lerpDouble(collapsedWidth, expandedContentWidth, expansion), + height: lerpDouble(collapsedHeight, expandedHeight, expansion), + child: Stack( + children: [ + Positioned( + left: 0, + top: 0, + child: Opacity( + opacity: 1 - expansion, + child: _buildCollapsedLayout(context, t, focused: focused), + ), + ), + Positioned( + left: 0, + top: 0, + child: Opacity( + opacity: expansion, + child: SizedBox( + width: expandedContentWidth, + height: expandedHeight, + child: _buildExpandedLayout(t, focused: focused, fixedHeight: true), + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final t = tokens(context); - final showSelectedBackground = isSelected && !suppressSelectedBackground; return ListenableBuilder( listenable: focusNode, builder: (context, _) { final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context); + final content = collapsedLabel == null + ? SizedBox( + width: expandedContentWidth, + child: _buildExpandedLayout(t, focused: focused, fixedHeight: false), + ) + : TweenAnimationBuilder( + tween: Tween(end: isCollapsed ? 0.0 : 1.0), + duration: SideNavigationRailState.expandDuration, + curve: SideNavigationRailState.expandCurve, + builder: (context, expansion, _) => + _buildMorphingLayouts(context, t, focused: focused, expansion: expansion), + ); return Focus( focusNode: focusNode, autofocus: autofocus, @@ -139,51 +293,12 @@ class NavigationRailItem extends StatelessWidget { child: InkWell( canRequestFocus: false, onTap: onTap, - borderRadius: borderRadius, - child: Container( - decoration: BoxDecoration( - color: () { - if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null; - if (focused) { - return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha); - } - if (showSelectedBackground) return t.text.withValues(alpha: 0.1); - return null; - }(), - borderRadius: borderRadius, - ), + borderRadius: BorderRadius.circular(MonoTokens.radiusFull), + child: UnconstrainedBox( + alignment: .centerLeft, + constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, - child: UnconstrainedBox( - alignment: .centerLeft, - constrainedAxis: Axis.vertical, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: SideNavigationRailState.expandedWidth - 24, - child: Padding( - padding: .symmetric(vertical: 12, horizontal: horizontalPadding), - child: Row( - children: [ - iconWidget ?? - 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); - }(), - ), - ?trailing, - ], - ), - ), - ), - ), + child: content, ), ), ), @@ -207,12 +322,13 @@ class SideNavigationRail extends StatefulWidget { /// Called when RIGHT arrow is pressed to navigate to content without selecting. final VoidCallback? onNavigateToContent; - /// Called when hover/touch expansion changes, so the shell can reserve width. - final ValueChanged? onInteractionExpandedChanged; - /// Called when the user taps the reconnect button in offline mode. final VoidCallback? onReconnect; + /// Called when hover/touch expansion changes, so the shell can scrim the + /// content the modal rail overlays. + final ValueChanged? onInteractionExpandedChanged; + const SideNavigationRail({ super.key, required this.selectedTab, @@ -246,25 +362,27 @@ class SideNavigationRailState extends State with MountedSetS static const double tvCollapsedWidth = 48.0; static const double expandedWidth = 220.0; static const double _horizontalPadding = 12.0; - static const double _itemHorizontalPadding = 17.0; - static const double _defaultIconSize = 22.0; + static const double _collapsedHorizontalPadding = 4.0; + static const double _itemGap = 4.0; static const Duration _collapseDelay = Duration(milliseconds: 150); + /// Trailing corner radius of the floating (hover/touch) overlay panel; the + /// docked open rail keeps the regular radiusLg card rounding. + static const double overlayCornerRadius = 32.0; + + /// Collapse/expand morph timing, shared by the rail width, every item's + /// layout morph, and the shell's content translate + bleed counter + /// animations (MainScreen and SideNavigationBleedBuilder) so they all + /// track tick for tick. + static const Duration expandDuration = Duration(milliseconds: 250); + static const Curve expandCurve = Curves.easeInOutCubicEmphasized; + static double collapsedWidthForContext(BuildContext _) => PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth; - static double itemHorizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) { - if (isCollapsed && PlatformDetector.isTV()) { - return ((tvCollapsedWidth - _defaultIconSize) / 2).clamp(0.0, _itemHorizontalPadding).toDouble(); - } - return _itemHorizontalPadding; - } - - static double horizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) { - if (!isCollapsed) return _horizontalPadding; - final centeredPadding = - ((collapsedWidthForContext(context) - _defaultIconSize) / 2) - - itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); - return centeredPadding.clamp(0.0, _horizontalPadding).toDouble(); + /// Edge padding around the item column; collapsed rails center their items + /// (72px in the 80px desktop strip, 40px in the 48px TV strip). + static double horizontalPaddingForContext(BuildContext _, {required bool isCollapsed}) { + return isCollapsed ? _collapsedHorizontalPadding : _horizontalPadding; } static const _kHome = 'home'; @@ -322,6 +440,8 @@ class SideNavigationRailState extends State with MountedSetS } } + /// didUpdateWidget runs during build, where notifying the parent would + /// setState mid-build; defer that report to the next frame. void _scheduleInteractionExpandedNotification() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -651,7 +771,6 @@ class SideNavigationRailState extends State with MountedSetS final isCollapsed = !_shouldExpand; final effectiveCollapsedWidth = collapsedWidthForContext(context); final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed); - final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); final hasLiveTv = context.watch().hasLiveTv; // Nullable watch: rail tests (and any host without the profile session // scope) simply never show the Explore item. @@ -725,19 +844,42 @@ class SideNavigationRailState extends State with MountedSetS behavior: HitTestBehavior.opaque, onTap: isCollapsed ? _expandForTouch : null, child: AnimatedContainer( - duration: t.normal, - curve: Curves.easeOutCubic, + duration: expandDuration, + curve: expandCurve, width: isCollapsed ? effectiveCollapsedWidth : expandedWidth, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(), child: Stack( children: [ + // Desktop surface. Hover/touch expansion is an M3E + // modal rail: it overlays the content (the shell keeps + // its collapsed offset), so it casts an edge shadow. Positioned.fill( child: AnimatedOpacity( opacity: PlatformDetector.isTV() ? 0.0 : 1.0, duration: t.normal, curve: Curves.easeOutCubic, - child: ColoredBox(color: t.surface), + child: AnimatedContainer( + duration: expandDuration, + curve: expandCurve, + decoration: BoxDecoration( + color: t.surface, + // Open rails read as an M3E panel: rounded + // trailing corners, extra-rounded plus an edge + // shadow while floating over content (modal) + // instead of pushing it. + borderRadius: isCollapsed + ? BorderRadius.zero + : BorderRadius.horizontal( + right: Radius.circular( + _interactionExpanded && !widget.alwaysExpanded ? overlayCornerRadius : t.radiusLg, + ), + ), + boxShadow: _interactionExpanded && !widget.alwaysExpanded + ? [BoxShadow(color: Colors.black.withValues(alpha: 0.35), blurRadius: 24)] + : const [], + ), + ), ), ), IgnorePointer( @@ -750,103 +892,109 @@ class SideNavigationRailState extends State with MountedSetS children: [ SizedBox(height: _getTopPadding(context)), Expanded( - child: ListView( + child: AnimatedPadding( padding: .symmetric(horizontal: horizontalPadding), - clipBehavior: Clip.hardEdge, - children: [ - if (widget.isOfflineMode && widget.onReconnect != null) ...[ - _buildReconnectItem(isCollapsed: isCollapsed), - const SizedBox(height: 8), - ], - if (!widget.isOfflineMode) ...[ - _buildNavItem( - icon: Symbols.home_rounded, - selectedIcon: Symbols.home_rounded, - label: Translations.of(context).common.home, - isSelected: widget.selectedTab == NavigationTabId.discover, - onTap: () => widget.onDestinationSelected(NavigationTabId.discover), - focusNode: _focusTracker.get(_kHome), - isCollapsed: isCollapsed, - ), - const SizedBox(height: 8), - // Now Playing — only while a music session is live. - if (nowPlayingTrack != null && musicService != null) ...[ - _buildNowPlayingItem(nowPlayingTrack, musicService, isCollapsed: isCollapsed), - const SizedBox(height: 8), + duration: expandDuration, + curve: expandCurve, + child: ListView( + padding: EdgeInsets.zero, + clipBehavior: Clip.hardEdge, + children: [ + if (widget.isOfflineMode && widget.onReconnect != null) ...[ + _buildReconnectItem(isCollapsed: isCollapsed), + const SizedBox(height: _itemGap), ], - _buildLibrariesSection( - visibleRows, - hiddenRows, - hiddenLibraries.length, - t, - isCollapsed: isCollapsed, - itemHorizontalPadding: itemHorizontalPadding, - ), - const SizedBox(height: 8), - if (context.watch().hasLiveTv) ...[ + if (!widget.isOfflineMode) ...[ _buildNavItem( - icon: Symbols.live_tv_rounded, - selectedIcon: Symbols.live_tv_rounded, - label: Translations.of(context).navigation.liveTv, - isSelected: widget.selectedTab == NavigationTabId.liveTv, - onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv), - focusNode: _focusTracker.get('liveTv'), + icon: Symbols.home_rounded, + selectedIcon: Symbols.home_rounded, + label: Translations.of(context).common.home, + isSelected: widget.selectedTab == NavigationTabId.discover, + onTap: () => widget.onDestinationSelected(NavigationTabId.discover), + focusNode: _focusTracker.get(_kHome), isCollapsed: isCollapsed, ), - const SizedBox(height: 8), - ], - if (hasExplore) ...[ - _buildNavItem( - icon: Symbols.explore_rounded, - selectedIcon: Symbols.explore_rounded, - label: Translations.of(context).navigation.explore, - isSelected: widget.selectedTab == NavigationTabId.explore, - onTap: () => widget.onDestinationSelected(NavigationTabId.explore), - focusNode: _focusTracker.get(_kExplore), + const SizedBox(height: _itemGap), + // Now Playing — only while a music session is live. + if (nowPlayingTrack != null && musicService != null) ...[ + _buildNowPlayingItem(nowPlayingTrack, musicService, isCollapsed: isCollapsed), + const SizedBox(height: _itemGap), + ], + _buildLibrariesSection( + visibleRows, + hiddenRows, + hiddenLibraries.length, + t, isCollapsed: isCollapsed, ), - const SizedBox(height: 8), + const SizedBox(height: _itemGap), + if (context.watch().hasLiveTv) ...[ + _buildNavItem( + icon: Symbols.live_tv_rounded, + selectedIcon: Symbols.live_tv_rounded, + label: Translations.of(context).navigation.liveTv, + isSelected: widget.selectedTab == NavigationTabId.liveTv, + onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv), + focusNode: _focusTracker.get('liveTv'), + isCollapsed: isCollapsed, + ), + const SizedBox(height: _itemGap), + ], + if (hasExplore) ...[ + _buildNavItem( + icon: Symbols.explore_rounded, + selectedIcon: Symbols.explore_rounded, + label: Translations.of(context).navigation.explore, + isSelected: widget.selectedTab == NavigationTabId.explore, + onTap: () => widget.onDestinationSelected(NavigationTabId.explore), + focusNode: _focusTracker.get(_kExplore), + isCollapsed: isCollapsed, + ), + const SizedBox(height: _itemGap), + ], + _buildNavItem( + icon: Symbols.search_rounded, + selectedIcon: Symbols.search_rounded, + label: Translations.of(context).common.search, + isSelected: widget.selectedTab == NavigationTabId.search, + onTap: () => widget.onDestinationSelected(NavigationTabId.search), + focusNode: _focusTracker.get(_kSearch), + isCollapsed: isCollapsed, + ), + const SizedBox(height: _itemGap), + ], + // Downloads (hidden on Apple TV — no user + // file storage) + if (_showDownloads) ...[ + _buildNavItem( + icon: Symbols.download_rounded, + selectedIcon: Symbols.download_rounded, + label: Translations.of(context).navigation.downloads, + isSelected: widget.selectedTab == NavigationTabId.downloads, + onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), + focusNode: _focusTracker.get(_kDownloads), + isCollapsed: isCollapsed, + ), + const SizedBox(height: _itemGap), ], _buildNavItem( - icon: Symbols.search_rounded, - selectedIcon: Symbols.search_rounded, - label: Translations.of(context).common.search, - isSelected: widget.selectedTab == NavigationTabId.search, - onTap: () => widget.onDestinationSelected(NavigationTabId.search), - focusNode: _focusTracker.get(_kSearch), + icon: Symbols.settings_rounded, + selectedIcon: Symbols.settings_rounded, + label: Translations.of(context).common.settings, + isSelected: widget.selectedTab == NavigationTabId.settings, + onTap: () => widget.onDestinationSelected(NavigationTabId.settings), + focusNode: _focusTracker.get(_kSettings), isCollapsed: isCollapsed, ), - const SizedBox(height: 8), ], - // Downloads (hidden on Apple TV — no user - // file storage) - if (_showDownloads) ...[ - _buildNavItem( - icon: Symbols.download_rounded, - selectedIcon: Symbols.download_rounded, - label: Translations.of(context).navigation.downloads, - isSelected: widget.selectedTab == NavigationTabId.downloads, - onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), - focusNode: _focusTracker.get(_kDownloads), - isCollapsed: isCollapsed, - ), - const SizedBox(height: 8), - ], - _buildNavItem( - icon: Symbols.settings_rounded, - selectedIcon: Symbols.settings_rounded, - label: Translations.of(context).common.settings, - isSelected: widget.selectedTab == NavigationTabId.settings, - onTap: () => widget.onDestinationSelected(NavigationTabId.settings), - focusNode: _focusTracker.get(_kSettings), - isCollapsed: isCollapsed, - ), - ], + ), ), ), if (_showFullscreenToggle) - Padding( + AnimatedPadding( padding: .fromLTRB(horizontalPadding, 0, horizontalPadding, 12), + duration: expandDuration, + curve: expandCurve, child: _buildFullscreenItem(isCollapsed: isCollapsed), ), ], @@ -874,7 +1022,6 @@ class SideNavigationRailState extends State with MountedSetS bool autofocus = false, }) { final t = tokens(context); - final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: icon, @@ -889,12 +1036,12 @@ class SideNavigationRailState extends State with MountedSetS overflow: .ellipsis, maxLines: 1, ), + collapsedLabel: label, isSelected: isSelected, isCollapsed: isCollapsed, onTap: onTap, focusNode: focusNode, autofocus: autofocus, - horizontalPadding: itemHorizontalPadding, suppressSelectedBackground: widget.isSidebarFocused, onNavigateRight: widget.onNavigateToContent, ); @@ -905,7 +1052,6 @@ class SideNavigationRailState extends State with MountedSetS /// opens the now-playing screen. Widget _buildNowPlayingItem(MediaItem track, MusicPlaybackService musicService, {required bool isCollapsed}) { final t = tokens(context); - final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: Symbols.music_note_rounded, @@ -933,19 +1079,17 @@ class SideNavigationRailState extends State with MountedSetS ), ], ), + collapsedLabel: Translations.of(context).music.nowPlaying, isSelected: false, isCollapsed: isCollapsed, - useSimpleLayout: true, onTap: () => unawaited(openNowPlaying(context)), focusNode: _focusTracker.get(_kNowPlaying), - horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } Widget _buildReconnectItem({required bool isCollapsed}) { final t = tokens(context); - final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: widget.isReconnecting ? Symbols.sync_rounded : Symbols.wifi_rounded, @@ -957,12 +1101,12 @@ class SideNavigationRailState extends State with MountedSetS overflow: .ellipsis, maxLines: 1, ), + collapsedLabel: Translations.of(context).common.reconnect, isSelected: false, isCollapsed: isCollapsed, // ignore: no-empty-block - no-op tap handler while reconnecting onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(), focusNode: _focusTracker.get(_kReconnect), - horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } @@ -970,21 +1114,23 @@ class SideNavigationRailState extends State with MountedSetS Widget _buildFullscreenItem({required bool isCollapsed}) { final t = tokens(context); final isFullscreen = FullscreenStateManager().isFullscreen; - final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); + final label = isFullscreen + ? Translations.of(context).common.exitFullscreen + : Translations.of(context).common.fullscreen; return NavigationRailItem( icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, label: Text( - isFullscreen ? Translations.of(context).common.exitFullscreen : Translations.of(context).common.fullscreen, + label, style: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted), overflow: .ellipsis, maxLines: 1, ), + collapsedLabel: label, isSelected: false, isCollapsed: isCollapsed, onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()), focusNode: _focusTracker.get(_kFullscreen), - horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } @@ -995,7 +1141,6 @@ class SideNavigationRailState extends State with MountedSetS int hiddenLibraryCount, dynamic t, { bool isCollapsed = false, - required double itemHorizontalPadding, }) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; @@ -1005,6 +1150,9 @@ class SideNavigationRailState extends State with MountedSetS return Column( crossAxisAlignment: .start, children: [ + // A destination-sized row like Home/Search — same height, icon and + // label metrics — with the chevron as the expand affordance. The + // smaller header type is reserved for the nested sub-headers. NavigationRailItem( icon: Symbols.video_library_rounded, label: Text( @@ -1014,24 +1162,21 @@ class SideNavigationRailState extends State with MountedSetS fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400, color: isLibrariesTabSelected ? t.text : t.textMuted, ), + overflow: .ellipsis, + maxLines: 1, ), - trailing: 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, - ), + collapsedLabel: Translations.of(context).navigation.libraries, + trailing: AppIcon( + _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, + fill: 1, + size: 18, + color: t.textMuted, ), isSelected: isLibrariesTabSelected, isCollapsed: isCollapsed, onTap: () => unawaited(SettingsService.instance.write(SettingsService.librariesSectionExpanded, !_librariesExpanded)), focusNode: _focusTracker.get(_kLibraries), - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - horizontalPadding: itemHorizontalPadding, // A selected library owns the highlight; the header only shows it // for the bare Libraries tab. suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null, @@ -1042,8 +1187,8 @@ class SideNavigationRailState extends State with MountedSetS TweenAnimationBuilder( tween: Tween(end: (_librariesExpanded && !isCollapsed) ? 1.0 : 0.0), - duration: tokens(context).normal, - curve: Curves.easeOutCubic, + duration: expandDuration, + curve: expandCurve, builder: (context, value, child) { return ClipRect( child: Align(alignment: .topCenter, heightFactor: value, child: child), @@ -1176,53 +1321,51 @@ class SideNavigationRailState extends State with MountedSetS required dynamic t, }) { final focusNode = _focusTracker.get(focusKey); - final radius = BorderRadius.circular(tokens(context).radiusSm); - // Match library-item indent: outer Padding(left: 12) + inner horizontal 17. - return Padding( - padding: const EdgeInsets.only(left: 12), - child: ListenableBuilder( - listenable: focusNode, - builder: (context, _) => Focus( - focusNode: focusNode, - onKeyEvent: (node, event) => - _handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent), - child: Material( - color: Colors.transparent, - child: InkWell( - canRequestFocus: false, - onTap: onToggle, - borderRadius: radius, - child: Container( - decoration: BoxDecoration( - color: focusNode.hasFocus && InputModeTracker.isKeyboardMode(context) - ? t.text.withValues(alpha: 0.08) - : null, - borderRadius: radius, - ), + final radius = BorderRadius.circular(MonoTokens.radiusFull); + // Sub-section header at full rail width; the smaller type alone carries + // the hierarchy, matching M3E expanded-rail sections. + return ListenableBuilder( + listenable: focusNode, + builder: (context, _) => Focus( + focusNode: focusNode, + onKeyEvent: (node, event) => + _handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent), + child: Material( + color: Colors.transparent, + child: InkWell( + canRequestFocus: false, + onTap: onToggle, + borderRadius: radius, + child: Container( + decoration: BoxDecoration( + color: focusNode.hasFocus && InputModeTracker.isKeyboardMode(context) + ? t.text.withValues(alpha: 0.08) + : null, + borderRadius: radius, + ), + clipBehavior: Clip.hardEdge, + child: UnconstrainedBox( + alignment: .centerLeft, + constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, - child: UnconstrainedBox( - alignment: .centerLeft, - constrainedAxis: Axis.vertical, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: expandedWidth - 24, - child: Padding( - padding: .symmetric(vertical: verticalPadding, horizontal: 17), - child: Row( - children: [ - leading ?? AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted), - const SizedBox(width: 11), - Expanded( - child: Text(label, style: labelStyle, overflow: .ellipsis), - ), - AppIcon( - isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, - fill: 1, - size: 16, - color: t.textMuted, - ), - ], - ), + child: SizedBox( + width: expandedWidth - 24, + child: Padding( + padding: .symmetric(vertical: verticalPadding, horizontal: 17), + child: Row( + children: [ + leading ?? AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted), + const SizedBox(width: 11), + Expanded( + child: Text(label, style: labelStyle, overflow: .ellipsis), + ), + AppIcon( + isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, + fill: 1, + size: 16, + color: t.textMuted, + ), + ], ), ), ), @@ -1240,41 +1383,37 @@ class SideNavigationRailState extends State with MountedSetS final focusKey = _libraryItemFocusKey(section, library); final focusNode = _focusTracker.get(focusKey); - return Padding( - padding: const EdgeInsets.only(left: 12), - child: NavigationRailItem( - icon: ContentTypeHelper.getLibraryIcon(library.kind.id), - selectedIcon: ContentTypeHelper.getLibraryIcon(library.kind.id), - label: Column( - crossAxisAlignment: .start, - mainAxisSize: .min, - children: [ + return NavigationRailItem( + icon: ContentTypeHelper.getLibraryIcon(library.kind.id), + selectedIcon: ContentTypeHelper.getLibraryIcon(library.kind.id), + label: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + library.title, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? t.text : t.textMuted, + ), + overflow: .ellipsis, + ), + if (showServerName) Text( - library.title, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected ? t.text : t.textMuted, - ), + library.serverName!, + style: TextStyle(fontSize: 9, color: t.textMuted.withValues(alpha: 0.4)), overflow: .ellipsis, ), - if (showServerName) - Text( - library.serverName!, - style: TextStyle(fontSize: 9, color: t.textMuted.withValues(alpha: 0.4)), - overflow: .ellipsis, - ), - ], - ), - isSelected: isSelected, - useSimpleLayout: true, - onTap: () => widget.onLibrarySelected(library.globalKey), - focusNode: focusNode, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - iconSize: 18, - suppressSelectedBackground: widget.isSidebarFocused, - onNavigateRight: widget.onNavigateToContent, + ], ), + isSelected: isSelected, + onTap: () => widget.onLibrarySelected(library.globalKey), + focusNode: focusNode, + iconSize: 18, + expandedHeight: 40, + suppressSelectedBackground: widget.isSidebarFocused, + onNavigateRight: widget.onNavigateToContent, ); } } diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 7cd1b92cd..f8816dabc 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -17,6 +17,7 @@ import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; @@ -63,7 +64,7 @@ AnimatedOpacity _railSurfaceOpacity(WidgetTester tester) { .widgetList( find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedOpacity)), ) - .singleWhere((widget) => widget.child is ColoredBox); + .singleWhere((widget) => widget.child is AnimatedContainer); } /// The Libraries header's expand/collapse chevron, matched by the symbol that @@ -205,6 +206,38 @@ void main() { expect(_railSurfaceOpacity(tester).opacity, 1.0); }); + testWidgets('closed desktop rail shows icon-only pill destinations', (tester) async { + await _pumpBasicRail(tester); + + // Collapsed destinations are icon-only; labels appear only expanded. + expect(find.text('Home'), findsNothing); + + final homeItem = find.byType(NavigationRailItem).first; + final pillFinder = find.descendant(of: homeItem, matching: find.byType(Container)).first; + expect( + tester.getSize(pillFinder), + const Size(NavigationRailItem.collapsedIndicatorWidth, NavigationRailItem.collapsedIndicatorHeight), + ); + // Home is the selected tab: the pill carries the active indicator tint. + expect(_railItemDecoration(tester, homeItem)?.color, testMonoTokens.text.withValues(alpha: 0.1)); + + // Pill (and its icon) are centered in the rail. + final rail = find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedContainer)).first; + expect( + tester.getCenter(pillFinder).dx - tester.getTopLeft(rail).dx, + closeTo(SideNavigationRailState.collapsedWidth / 2, 0.1), + ); + }); + + testWidgets('expanded rail destination uses a full-width stadium indicator', (tester) async { + await _pumpBasicRail(tester, alwaysExpanded: true); + + final homeItem = find.byType(NavigationRailItem).first; + final indicator = find.descendant(of: homeItem, matching: find.byType(Container)).first; + expect(tester.getSize(indicator), const Size(SideNavigationRailState.expandedWidth - 24, 48)); + expect(_railItemDecoration(tester, homeItem)?.borderRadius, BorderRadius.circular(MonoTokens.radiusFull)); + }); + testWidgets('expanded TV rail keeps a transparent surface', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); @@ -404,7 +437,7 @@ void main() { expect(selectedTab, NavigationTabId.search); }); - testWidgets('reports interaction expansion for shell content push', (tester) async { + testWidgets('hover expands the rail as an overlay and collapses on exit', (tester) async { await SettingsService.getInstance(); final librariesProvider = LibrariesProvider(); @@ -418,7 +451,7 @@ void main() { final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); - final reports = []; + final scrimReports = []; await tester.pumpWidget( TranslationProvider( @@ -435,8 +468,8 @@ void main() { selectedTab: NavigationTabId.discover, isSidebarFocused: false, alwaysExpanded: false, - onInteractionExpandedChanged: reports.add, onDestinationSelected: (_) {}, + onInteractionExpandedChanged: scrimReports.add, onLibrarySelected: (_) {}, ), ), @@ -456,13 +489,24 @@ void main() { await gesture.moveTo(tester.getCenter(rail)); await tester.pumpAndSettle(); - expect(reports.last, isTrue); + // Floating overlays read as an M3E modal panel: extra-rounded trailing + // corners and an edge shadow, and the shell is told to scrim content. + final surface = _railSurfaceOpacity(tester).child! as AnimatedContainer; + final surfaceDecoration = surface.decoration! as BoxDecoration; + expect( + surfaceDecoration.borderRadius, + const BorderRadius.horizontal(right: Radius.circular(SideNavigationRailState.overlayCornerRadius)), + ); + expect(surfaceDecoration.boxShadow, isNotEmpty); + expect(scrimReports.last, isTrue); + expect(tester.getSize(rail).width, SideNavigationRailState.expandedWidth); await gesture.moveTo(tester.getBottomRight(rail) + const Offset(100, -10)); - await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); - expect(reports.last, isFalse); + expect(tester.getSize(rail).width, SideNavigationRailState.collapsedWidth); + expect(scrimReports.last, isFalse); }); testWidgets('Apple TV D-pad focus skips hidden downloads item', (tester) async {