From 6eaaff457daacd9ec3745c91725ff1fe3ed6b0f8 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Sat, 6 Jun 2026 10:56:25 -0400 Subject: [PATCH] Add collapsible navigation rail in wide layout Let users collapse the wide-mode NavigationRail to an icons-only compact view to reclaim horizontal space. A bottom-pinned double-chevron toggle sits centred when compact and slides to the rail's right side when extended, animating in sync with the rail. The choice is persisted via the nav_rail_extended preference (defaults to extended). Co-Authored-By: Claude Opus 4.8 --- frontend/lib/navigation.dart | 146 +++++++++++++----- .../widget/navigation_rail_toggle_test.dart | 90 +++++++++++ 2 files changed, 196 insertions(+), 40 deletions(-) create mode 100644 frontend/test/widget/navigation_rail_toggle_test.dart diff --git a/frontend/lib/navigation.dart b/frontend/lib/navigation.dart index b5a5ce5..c012ae0 100644 --- a/frontend/lib/navigation.dart +++ b/frontend/lib/navigation.dart @@ -91,12 +91,37 @@ final GoRouter router = GoRouter( ], ); +// Preference key controlling whether the wide-mode navigation rail shows +// labels (extended) or collapses to an icons-only compact view. +const String _kNavRailExtendedPref = 'nav_rail_extended'; + +// Fixed rail widths (M3 NavigationRail defaults), set explicitly so the +// collapse/expand toggle can be positioned exactly within the rail. +const double _kRailCompactWidth = 80; +const double _kRailExtendedWidth = 256; + // Application shell and navigation -class MainShell extends StatelessWidget { +class MainShell extends StatefulWidget { final Widget child; const MainShell({super.key, required this.child}); + @override + State createState() => _MainShellState(); +} + +class _MainShellState extends State { + // User preference: when on a wide screen, keep the rail extended (labels) or + // collapse it to icons only to reclaim horizontal space. Defaults to true so + // existing installs keep the labelled rail they had before. + bool _navRailExtended = + PrefUtil.getValue(_kNavRailExtendedPref, true) as bool; + + void _toggleNavRailExtended() { + setState(() => _navRailExtended = !_navRailExtended); + PrefUtil.setValue(_kNavRailExtendedPref, _navRailExtended); + } + @override Widget build(BuildContext context) { return LayoutBuilder( @@ -116,7 +141,7 @@ class MainShell extends StatelessWidget { Expanded( child: Padding( padding: const EdgeInsets.all(Insets.lg), - child: child, + child: widget.child, ), ), ], @@ -139,50 +164,91 @@ class MainShell extends StatelessWidget { ); } + final mediaPadding = MediaQuery.paddingOf(context); return Scaffold( appBar: _buildAppBar(context, selectedIndex), - body: Row( + // The body is a Stack so the collapse/expand toggle can be positioned + // freely within the rail (the rail's own slots give it an unbounded + // width, which prevents reliable horizontal alignment). + body: Stack( children: [ - SafeArea( - child: NavigationRail( - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - extended: width >= Breakpoints.expanded, - destinations: _destinations - .map( - (d) => NavigationRailDestination( - icon: Icon(d.icon), - selectedIcon: Icon(d.activeIcon), - label: Text(d.label), - ), - ) - .toList(), - selectedIndex: selectedIndex, - onDestinationSelected: (index) => - _onDestinationSelected(index, context), - ), - ), - Expanded( - child: Container( - color: Theme.of(context).colorScheme.surface, - // The overlay pins the pagination progress bar flush against - // the very bottom of the content region (the screen bottom). - child: PaginationProgressOverlay( - child: Column( - children: [ - const OfflineBanner(), - Expanded( - child: Padding( - padding: const EdgeInsets.all(Insets.lg), - child: child, - ), - ), - ], + Row( + children: [ + SafeArea( + child: NavigationRail( + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerLow, + minWidth: _kRailCompactWidth, + minExtendedWidth: _kRailExtendedWidth, + extended: + width >= Breakpoints.expanded && _navRailExtended, + destinations: _destinations + .map( + (d) => NavigationRailDestination( + icon: Icon(d.icon), + selectedIcon: Icon(d.activeIcon), + label: Text(d.label), + ), + ) + .toList(), + selectedIndex: selectedIndex, + onDestinationSelected: (index) => + _onDestinationSelected(index, context), ), ), - ), + Expanded( + child: Container( + color: Theme.of(context).colorScheme.surface, + // The overlay pins the pagination progress bar flush + // against the very bottom of the content region (the + // screen bottom). + child: PaginationProgressOverlay( + child: Column( + children: [ + const OfflineBanner(), + Expanded( + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: widget.child, + ), + ), + ], + ), + ), + ), + ), + ], ), + // The collapse/expand toggle, pinned to the bottom of the rail. + // It sits centred like the other icons when the rail is compact + // and slides to the rail's right side when it is extended. The + // double chevron points the way the rail will move: inward («) to + // collapse, outward (») to expand. + if (width >= Breakpoints.expanded) + AnimatedPositioned( + // Match the rail's own extend/collapse animation so the toggle + // slides with the edge instead of jumping after it settles. + duration: kThemeAnimationDuration, + curve: Curves.easeInOut, + bottom: mediaPadding.bottom + Insets.md, + left: _navRailExtended + ? mediaPadding.left + + _kRailExtendedWidth - + kMinInteractiveDimension - + Insets.sm + : mediaPadding.left + + (_kRailCompactWidth - kMinInteractiveDimension) / 2, + child: IconButton( + icon: Icon( + _navRailExtended + ? Icons.keyboard_double_arrow_left + : Icons.keyboard_double_arrow_right, + ), + tooltip: _navRailExtended ? 'Collapse menu' : 'Expand menu', + onPressed: _toggleNavRailExtended, + ), + ), ], ), ); diff --git a/frontend/test/widget/navigation_rail_toggle_test.dart b/frontend/test/widget/navigation_rail_toggle_test.dart new file mode 100644 index 0000000..f8a0804 --- /dev/null +++ b/frontend/test/widget/navigation_rail_toggle_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/navigation.dart'; +import 'package:frontend/utils/pref_utils.dart'; +import 'package:go_router/go_router.dart'; + +import '../helpers/backend_test_harness.dart'; + +void main() { + // A bare router whose shell is the real [MainShell]; the route bodies are + // trivial so the test exercises only the navigation chrome, not the screens. + GoRouter buildRouter() => GoRouter( + initialLocation: '/', + routes: [ + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute(path: '/', builder: (_, _) => const Text('home-body')), + ], + ), + ], + ); + + // A plain [ThemeData] (no Google Fonts) keeps the AppBar renderable in tests. + Future pumpShell(WidgetTester tester, {required Size size}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp.router(theme: ThemeData(), routerConfig: buildRouter()), + ); + await tester.pumpAndSettle(); + } + + NavigationRail rail(WidgetTester tester) => + tester.widget(find.byType(NavigationRail)); + + group('wide-mode navigation rail toggle', () { + setUp(() async { + await setUpBackendForTest(); + }); + + testWidgets('extends by default and shows a collapse action', ( + tester, + ) async { + await pumpShell(tester, size: const Size(1200, 900)); + + expect(rail(tester).extended, isTrue); + expect(find.byTooltip('Collapse menu'), findsOneWidget); + }); + + testWidgets('collapsing switches the rail to icons only and persists', ( + tester, + ) async { + await pumpShell(tester, size: const Size(1200, 900)); + + await tester.tap(find.byTooltip('Collapse menu')); + await tester.pumpAndSettle(); + + expect(rail(tester).extended, isFalse); + expect(find.byTooltip('Expand menu'), findsOneWidget); + expect(PrefUtil.getValue('nav_rail_extended', true), isFalse); + }); + + testWidgets('honours a previously collapsed preference on launch', ( + tester, + ) async { + await PrefUtil.setValue('nav_rail_extended', false); + + await pumpShell(tester, size: const Size(1200, 900)); + + expect(rail(tester).extended, isFalse); + expect(find.byTooltip('Expand menu'), findsOneWidget); + }); + + testWidgets('offers no toggle below the expanded breakpoint', ( + tester, + ) async { + // Medium width (>= 600, < 840): rail is compact and the toggle is hidden. + await pumpShell(tester, size: const Size(700, 900)); + + expect(find.byType(NavigationRail), findsOneWidget); + expect(rail(tester).extended, isFalse); + expect(find.byTooltip('Collapse menu'), findsNothing); + expect(find.byTooltip('Expand menu'), findsNothing); + }); + }); +}