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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user