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.
This commit is contained in:
edde746
2026-08-19 09:11:18 +02:00
parent 4b6533d25b
commit 4a1111f435
4 changed files with 474 additions and 275 deletions
+7 -5
View File
@@ -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<MainScreenScopeAspect> {
/// 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<double>(
tween: Tween(end: targetBleed),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
duration: SideNavigationRailState.expandDuration,
curve: SideNavigationRailState.expandCurve,
builder: builder,
child: child,
);
+23 -9
View File
@@ -296,6 +296,9 @@ class _MainScreenState extends State<MainScreen>
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<MainScreen>
_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<MainScreen>
}
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<MainScreen>
return _handleBackKey(event);
},
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
duration: SideNavigationRailState.expandDuration,
curve: SideNavigationRailState.expandCurve,
tween: Tween<double>(end: targetContentOffset),
child: FocusScope(
node: _contentFocusScope,
@@ -1821,6 +1819,19 @@ class _MainScreenState extends State<MainScreen>
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<MainScreen>
isSidebarFocused: _isSidebarFocused,
alwaysExpanded: alwaysExpanded,
isReconnecting: _isReconnecting,
onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged,
onDestinationSelected: (tab) {
final restorePreviousFocus = tab == _currentTab;
_selectTab(tab);
@@ -1846,6 +1856,10 @@ class _MainScreenState extends State<MainScreen>
_focusContent(restorePreviousFocus: false);
},
onNavigateToContent: _focusContent,
onInteractionExpandedChanged: (expanded) {
if (_isSidebarInteractionExpanded == expanded) return;
setState(() => _isSidebarInteractionExpanded = expanded);
},
onReconnect: _triggerReconnect,
),
),
+393 -254
View File
@@ -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<double>(
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<bool>? 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<bool>? onInteractionExpandedChanged;
const SideNavigationRail({
super.key,
required this.selectedTab,
@@ -246,25 +362,27 @@ class SideNavigationRailState extends State<SideNavigationRail> 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<SideNavigationRail> 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<SideNavigationRail> 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<MultiServerProvider>().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<SideNavigationRail> 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<SideNavigationRail> 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<MultiServerProvider>().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<MultiServerProvider>().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<SideNavigationRail> 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<SideNavigationRail> 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<SideNavigationRail> 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<SideNavigationRail> 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<SideNavigationRail> 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<SideNavigationRail> 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<SideNavigationRail> with MountedSetS
int hiddenLibraryCount,
dynamic t, {
bool isCollapsed = false,
required double itemHorizontalPadding,
}) {
final librariesProvider = context.watch<LibrariesProvider>();
final isLoading = librariesProvider.isLoading;
@@ -1005,6 +1150,9 @@ class SideNavigationRailState extends State<SideNavigationRail> 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<SideNavigationRail> 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<SideNavigationRail> with MountedSetS
TweenAnimationBuilder<double>(
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<SideNavigationRail> 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<SideNavigationRail> 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,
);
}
}
+51 -7
View File
@@ -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<AnimatedOpacity>(
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 = <bool>[];
final scrimReports = <bool>[];
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 {