diff --git a/CHANGELOG.md b/CHANGELOG.md index 2300d050..5353170a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The package is available from ## 1.1 Consolidate Android Login ++ Overflow menu in More button on mobile nav bar [1.0.46 20260915 jesscmoore] + Plainer wording when a sign-in does not complete [1.0.46 20260918 gjw] + Report offline rather than doing nothing on Login [1.0.45 20260915 gjw] + Name login errors: internet, address or server [1.0.44 20260915 gjw] diff --git a/lib/src/widgets/solid_nav_bottom_bar.dart b/lib/src/widgets/solid_nav_bottom_bar.dart index 2c512c2d..812e8a46 100644 --- a/lib/src/widgets/solid_nav_bottom_bar.dart +++ b/lib/src/widgets/solid_nav_bottom_bar.dart @@ -24,7 +24,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. /// -/// Authors: Tony Chen +/// Authors: Tony Chen, Jess Moore library; @@ -33,9 +33,15 @@ import 'package:flutter/material.dart'; import 'package:markdown_tooltip/markdown_tooltip.dart'; import 'package:solidui/src/constants/navigation.dart'; +import 'package:solidui/src/utils/is_phone.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; /// Bottom navigation bar showing main menu tabs on narrow screens. +/// +/// Tabs marked [SolidNavTab.showInOverflow] are collapsed into a "More" +/// destination that opens the remaining tabs in a menu when pressed. This +/// only happens on mobile platforms (iOS/Android); on web and desktop every +/// tab is shown directly, regardless of the flag. class SolidNavBottomBar extends StatelessWidget { /// Navigation tabs (typically from [SolidScaffold] menu items). @@ -71,11 +77,96 @@ class SolidNavBottomBar extends StatelessWidget { } } + /// Shows the tabs collapsed into overflow, triggered by pressing the + /// "More" destination. + + Future _showOverflowMenu( + BuildContext context, + List overflowIndices, + ) async { + final cs = Theme.of(context).colorScheme; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: overflowIndices.map((index) { + final tab = tabs[index]; + final isSelected = index == selectedIndex; + final tileColor = + isSelected ? cs.primary : (tab.color ?? cs.onSurfaceVariant); + + return ListTile( + leading: Icon(tab.icon, color: tileColor), + title: Text( + tab.title, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, + color: tileColor, + ), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _handleTabSelection(index, context); + }, + ); + }).toList(), + ), + ); + }, + ); + } + + /// Builds the icon widget for a tab, including its optional tooltip. + + Widget _buildTabIcon(SolidNavTab tab, bool isSelected, ColorScheme cs) { + Widget icon = Icon( + tab.icon, + size: NavigationConstants.navIconSize, + color: isSelected + ? cs.primary + : (tab.color ?? cs.onSurfaceVariant.withValues(alpha: 0.7)), + ); + + final tooltipMessage = tab.tooltip ?? tab.message; + if (tooltipMessage != null) { + icon = MarkdownTooltip(message: tooltipMessage, child: icon); + } + return icon; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final cs = theme.colorScheme; + // Only collapse tabs into the overflow "More" destination on mobile + // platforms; web/desktop narrow layouts always show every tab directly. + + final hasOverflow = isPhone() && tabs.any((tab) => tab.showInOverflow); + + final visibleIndices = [ + for (int i = 0; i < tabs.length; i++) + if (!hasOverflow || !tabs[i].showInOverflow) i, + ]; + final overflowIndices = [ + for (int i = 0; i < tabs.length; i++) + if (hasOverflow && tabs[i].showInOverflow) i, + ]; + + final moreDestinationIndex = visibleIndices.length; + final isOverflowSelected = overflowIndices.contains(selectedIndex); + + final navBarSelectedIndex = isOverflowSelected + ? moreDestinationIndex + : () { + final position = visibleIndices.indexOf(selectedIndex ?? -1); + return position == -1 ? 0 : position; + }(); + return NavigationBarTheme( data: NavigationBarThemeData( height: 64, @@ -91,31 +182,34 @@ class SolidNavBottomBar extends StatelessWidget { indicatorColor: cs.primaryContainer.withValues(alpha: 0.6), ), child: NavigationBar( - selectedIndex: selectedIndex ?? 0, - onDestinationSelected: (index) => _handleTabSelection(index, context), - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, - destinations: tabs.map((tab) { - final index = tabs.indexOf(tab); - final isSelected = index == selectedIndex; - final tooltipMessage = tab.tooltip ?? tab.message; - - Widget icon = Icon( - tab.icon, - size: NavigationConstants.navIconSize, - color: isSelected - ? cs.primary - : (tab.color ?? cs.onSurfaceVariant.withValues(alpha: 0.7)), - ); - - if (tooltipMessage != null) { - icon = MarkdownTooltip(message: tooltipMessage, child: icon); + selectedIndex: navBarSelectedIndex, + onDestinationSelected: (destinationIndex) { + if (overflowIndices.isNotEmpty && + destinationIndex == moreDestinationIndex) { + _showOverflowMenu(context, overflowIndices); + return; } - - return NavigationDestination( - icon: icon, - label: tab.title, - ); - }).toList(), + _handleTabSelection(visibleIndices[destinationIndex], context); + }, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + destinations: [ + for (final index in visibleIndices) + NavigationDestination( + icon: _buildTabIcon(tabs[index], index == selectedIndex, cs), + label: tabs[index].title, + ), + if (overflowIndices.isNotEmpty) + NavigationDestination( + icon: Icon( + Icons.more_horiz, + size: NavigationConstants.navIconSize, + color: isOverflowSelected + ? cs.primary + : cs.onSurfaceVariant.withValues(alpha: 0.7), + ), + label: 'More', + ), + ], ), ); } diff --git a/lib/src/widgets/solid_nav_models.dart b/lib/src/widgets/solid_nav_models.dart index fdbb0976..5831fac1 100644 --- a/lib/src/widgets/solid_nav_models.dart +++ b/lib/src/widgets/solid_nav_models.dart @@ -69,6 +69,12 @@ class SolidNavTab { final void Function(BuildContext)? action; + /// Whether this tab should be moved into the overflow "More" menu on + /// the bottom navigation bar. Only takes effect on mobile platforms + /// (iOS/Android); on other platforms the tab is always shown directly. + + final bool showInOverflow; + const SolidNavTab({ required this.title, required this.icon, @@ -78,6 +84,7 @@ class SolidNavTab { this.message, this.dialogTitle, this.action, + this.showInOverflow = false, }); } diff --git a/lib/src/widgets/solid_scaffold_helpers.dart b/lib/src/widgets/solid_scaffold_helpers.dart index 16642ca8..67e1930b 100644 --- a/lib/src/widgets/solid_scaffold_helpers.dart +++ b/lib/src/widgets/solid_scaffold_helpers.dart @@ -67,6 +67,7 @@ class SolidScaffoldHelpers { message: item.message, dialogTitle: item.dialogTitle, action: item.onTap, + showInOverflow: item.showInOverflow, ), ) .toList(); diff --git a/lib/src/widgets/solid_scaffold_models.dart b/lib/src/widgets/solid_scaffold_models.dart index 6f1726ec..17aeb8d1 100644 --- a/lib/src/widgets/solid_scaffold_models.dart +++ b/lib/src/widgets/solid_scaffold_models.dart @@ -74,6 +74,12 @@ class SolidMenuItem { final void Function(BuildContext)? onTap; + /// Whether this menu item should be moved into the overflow "More" menu + /// on the bottom navigation bar. Only takes effect on mobile platforms + /// (iOS/Android); on other platforms the item is always shown directly. + + final bool showInOverflow; + const SolidMenuItem({ required this.title, required this.icon, @@ -83,6 +89,7 @@ class SolidMenuItem { this.message, this.dialogTitle, this.onTap, + this.showInOverflow = false, }); } diff --git a/test/solid_nav_bottom_bar_test.dart b/test/solid_nav_bottom_bar_test.dart new file mode 100644 index 00000000..8e87280a --- /dev/null +++ b/test/solid_nav_bottom_bar_test.dart @@ -0,0 +1,149 @@ +/// Tests for the bottom navigation bar's overflow "More" menu. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:solidui/src/widgets/solid_nav_bottom_bar.dart'; +import 'package:solidui/src/widgets/solid_nav_models.dart'; + +Widget _buildBar({ + required List tabs, + int? selectedIndex, + required void Function(int) onTabSelected, +}) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: SolidNavBottomBar( + tabs: tabs, + selectedIndex: selectedIndex, + onTabSelected: onTabSelected, + ), + ), + ); +} + +void main() { + testWidgets( + 'shows all tabs directly when none are marked for overflow', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + const tabs = [ + SolidNavTab(title: 'Home', icon: Icons.home), + SolidNavTab(title: 'Files', icon: Icons.folder), + ]; + + await tester.pumpWidget( + _buildBar(tabs: tabs, selectedIndex: 0, onTabSelected: (_) {}), + ); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Files'), findsOneWidget); + expect(find.text('More'), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'collapses tabs marked showInOverflow into a More button on mobile', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + int? selected; + const tabs = [ + SolidNavTab(title: 'Home', icon: Icons.home), + SolidNavTab(title: 'Files', icon: Icons.folder), + SolidNavTab( + title: 'Settings', + icon: Icons.settings, + showInOverflow: true, + ), + ]; + + await tester.pumpWidget( + _buildBar( + tabs: tabs, + selectedIndex: 0, + onTabSelected: (i) => selected = i, + ), + ); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Files'), findsOneWidget); + expect(find.text('More'), findsOneWidget); + expect(find.text('Settings'), findsNothing); + + await tester.tap(find.text('More')); + await tester.pumpAndSettle(); + + expect(find.text('Settings'), findsOneWidget); + + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + + expect(selected, 2); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'does not collapse tabs into overflow on non-mobile platforms', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + const tabs = [ + SolidNavTab(title: 'Home', icon: Icons.home), + SolidNavTab( + title: 'Settings', + icon: Icons.settings, + showInOverflow: true, + ), + ]; + + await tester.pumpWidget( + _buildBar(tabs: tabs, selectedIndex: 0, onTabSelected: (_) {}), + ); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + expect(find.text('More'), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); +}