From 431b93f0fdce324361769df254543cba3d27b4e7 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:45:12 -0400 Subject: [PATCH] feat(#292): shared app shell with hamburger nav drawer and pin Epic A of the navigation redesign (#102). Adds AppShell, which owns the bottom QuickSwitchBar that Channels, Contacts, Map and Line-of-Sight each mounted separately, plus a left nav drawer: a transient slide-out on narrow layouts and a dockable panel that can be pinned open at >= 720px. - New lib/widgets/app_shell.dart. Reuses the 720px breakpoint established by settings_shell.dart. Pinned wide lays the panel out beside the body (Row), unpinned uses a standard Scaffold drawer so the hamburger is injected automatically; automaticallyImplyLeading:false was removed from the four app bars to allow that. - Pin state persists via UiViewStateService.navDrawerPinned. The load is placed before the channel-sort block in initialize(), which returns early and would otherwise skip it. - drawerContent is an empty slot here; the channel list fills it in Epic B (#289). Per owner decisions on #102 (2026-07-16): the bottom bar stays a bottom bar at every width (no left rail), and the drawer carries no view switcher, since the bottom bar owns view switching. flutter analyze clean, dart format applied. Not yet run on hardware. --- lib/screens/channels_screen.dart | 19 +-- lib/screens/contacts_screen.dart | 19 +-- lib/screens/line_of_sight_map_screen.dart | 25 ++-- lib/screens/map_screen.dart | 20 +-- lib/services/ui_view_state_service.dart | 14 ++ lib/widgets/app_shell.dart | 153 ++++++++++++++++++++++ 6 files changed, 196 insertions(+), 54 deletions(-) create mode 100644 lib/widgets/app_shell.dart diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 5e51d18..4dc1afc 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -23,7 +23,7 @@ import '../utils/route_transitions.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; -import '../widgets/quick_switch_bar.dart'; +import '../widgets/app_shell.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; @@ -100,11 +100,14 @@ class _ChannelsScreenState extends State return PopScope( canPop: allowBack, - child: Scaffold( + child: AppShell( + selectedIndex: 1, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), appBar: AppBar( title: AppBarTitle(context.l10n.channels_title), centerTitle: true, - automaticallyImplyLeading: false, bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( @@ -335,16 +338,6 @@ class _ChannelsScreenState extends State tooltip: context.l10n.channels_addChannel, child: const Icon(Icons.add), ), - bottomNavigationBar: SafeArea( - top: false, - child: QuickSwitchBar( - selectedIndex: 1, - onDestinationSelected: (index) => - _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - ), - ), ), ); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 4d178e0..2d15349 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -27,7 +27,7 @@ import '../utils/route_transitions.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/blocked_badge.dart'; -import '../widgets/quick_switch_bar.dart'; +import '../widgets/app_shell.dart'; import '../widgets/path_selection_dialog.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; @@ -319,10 +319,13 @@ class _ContactsScreenState extends State final allowBack = !connector.isConnected; return PopScope( canPop: allowBack, - child: Scaffold( + child: AppShell( + selectedIndex: 0, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), appBar: AppBar( title: AppBarTitle(context.l10n.contacts_title), - automaticallyImplyLeading: false, bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( @@ -430,16 +433,6 @@ class _ContactsScreenState extends State ], ), body: _buildContactsBody(context, connector), - bottomNavigationBar: SafeArea( - top: false, - child: QuickSwitchBar( - selectedIndex: 0, - onDestinationSelected: (index) => - _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - ), - ), ), ); } diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index f908f5e..4a68c22 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -17,7 +17,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/route_transitions.dart'; import '../connector/meshcore_connector.dart'; import '../widgets/app_bar.dart'; -import '../widgets/quick_switch_bar.dart'; +import '../widgets/app_shell.dart'; import '../icons/los_icon.dart'; class LineOfSightEndpoint { @@ -409,7 +409,15 @@ class _LineOfSightMapScreenState extends State { _showMarkerLabels = initialZoom >= _labelZoomThreshold; } - return Scaffold( + return AppShell( + selectedIndex: 2, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: context + .watch() + .getTotalContactsUnreadCount(), + channelsUnreadCount: context + .watch() + .getTotalChannelsUnreadCount(), appBar: AppBar( title: AppBarTitle(widget.title), centerTitle: true, @@ -534,19 +542,6 @@ class _LineOfSightMapScreenState extends State { : context.l10n.losShowPanelTooltip, child: Icon(_showHud ? Icons.visibility_off : Icons.tune), ), - bottomNavigationBar: SafeArea( - top: false, - child: QuickSwitchBar( - selectedIndex: 2, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), - contactsUnreadCount: context - .watch() - .getTotalContactsUnreadCount(), - channelsUnreadCount: context - .watch() - .getTotalChannelsUnreadCount(), - ), - ), ); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 3b1ae3f..1444f5a 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -23,7 +23,7 @@ import '../services/map_marker_service.dart'; import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; -import '../widgets/quick_switch_bar.dart'; +import '../widgets/app_shell.dart'; import '../widgets/sync_progress_overlay.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; @@ -412,11 +412,15 @@ class _MapScreenState extends State { return PopScope( canPop: allowBack, - child: Scaffold( + child: AppShell( + selectedIndex: 2, + onDestinationSelected: (index) => + _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), appBar: AppBar( title: AppBarTitle(context.l10n.map_title), centerTitle: true, - automaticallyImplyLeading: false, bottom: const SyncProgressAppBarBottom(), actions: [ if (!_isBuildingPathTrace) @@ -674,16 +678,6 @@ class _MapScreenState extends State { if (_isBuildingPathTrace) _buildPathTraceOverlay(), ], ), - bottomNavigationBar: SafeArea( - top: false, - child: QuickSwitchBar( - selectedIndex: 2, - onDestinationSelected: (index) => - _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - ), - ), floatingActionButton: FloatingActionButton( onPressed: () => _showFilterDialog(context, settingsService), tooltip: context.l10n.map_filterNodes, diff --git a/lib/services/ui_view_state_service.dart b/lib/services/ui_view_state_service.dart index 7f2a03a..bf3e47d 100644 --- a/lib/services/ui_view_state_service.dart +++ b/lib/services/ui_view_state_service.dart @@ -16,6 +16,7 @@ class UiViewStateService extends ChangeNotifier { static const _keyContactsTypeFilter = 'ui_contacts_type_filter'; static const _keyChannelsSortOption = 'ui_channels_sort_option'; static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index'; + static const _keyNavDrawerPinned = 'ui_nav_drawer_pinned'; String _contactsSelectedGroupName = contactsAllGroupsValue; String _contactsSearchText = ''; @@ -27,6 +28,8 @@ class UiViewStateService extends ChangeNotifier { String _channelsSearchText = ''; ChannelSortOption _channelsSortOption = ChannelSortOption.manual; + bool _navDrawerPinned = false; + String get contactsSelectedGroupName => _contactsSelectedGroupName; String get contactsSearchText => _contactsSearchText; bool get contactsSearchExpanded => _contactsSearchExpanded; @@ -35,6 +38,7 @@ class UiViewStateService extends ChangeNotifier { ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter; String get channelsSearchText => _channelsSearchText; ChannelSortOption get channelsSortOption => _channelsSortOption; + bool get navDrawerPinned => _navDrawerPinned; Future initialize() async { final prefs = PrefsManager.instance; @@ -63,6 +67,9 @@ class UiViewStateService extends ChangeNotifier { ); } + // Must load before the channel-sort block below, which returns early. + _navDrawerPinned = prefs.getBool(_keyNavDrawerPinned) ?? false; + final channelSortStr = prefs.getString(_keyChannelsSortOption); if (channelSortStr != null) { _channelsSortOption = ChannelSortOption.values.firstWhere( @@ -151,4 +158,11 @@ class UiViewStateService extends ChangeNotifier { PrefsManager.instance.setString(_keyChannelsSortOption, value.name), ); } + + void setNavDrawerPinned(bool value) { + if (_navDrawerPinned == value) return; + _navDrawerPinned = value; + notifyListeners(); + unawaited(PrefsManager.instance.setBool(_keyNavDrawerPinned, value)); + } } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart new file mode 100644 index 0000000..73680c0 --- /dev/null +++ b/lib/widgets/app_shell.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/ui_view_state_service.dart'; +import 'quick_switch_bar.dart'; + +/// Shared shell for the primary views (Contacts / Channels / Map). +/// +/// Owns the bottom [QuickSwitchBar] that each view previously mounted itself, +/// plus the left nav drawer: a transient slide-out on narrow layouts, and a +/// dockable pane that can be pinned open on wide ones. +/// +/// The bottom bar stays a bottom bar at every width; it never becomes a rail. +class AppShell extends StatelessWidget { + static const double wideBreakpoint = 720; + static const double _drawerWidth = 300; + + final int selectedIndex; + final ValueChanged onDestinationSelected; + final int contactsUnreadCount; + final int channelsUnreadCount; + + final PreferredSizeWidget? appBar; + final Widget body; + final Widget? floatingActionButton; + + /// List content for the active view. Null renders an empty drawer body. + final Widget? drawerContent; + + const AppShell({ + super.key, + required this.selectedIndex, + required this.onDestinationSelected, + required this.body, + this.appBar, + this.floatingActionButton, + this.drawerContent, + this.contactsUnreadCount = 0, + this.channelsUnreadCount = 0, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= wideBreakpoint; + final pinned = + isWide && context.watch().navDrawerPinned; + + return pinned + ? _buildPinned(context) + : _buildTransient(context, isWide); + }, + ); + } + + Widget _bottomBar() { + return SafeArea( + top: false, + child: QuickSwitchBar( + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + contactsUnreadCount: contactsUnreadCount, + channelsUnreadCount: channelsUnreadCount, + ), + ); + } + + /// Wide + pinned: the panel is laid out beside the body, not overlaid. + Widget _buildPinned(BuildContext context) { + return Scaffold( + appBar: appBar, + body: SafeArea( + top: false, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: _drawerWidth, + child: _NavPanel(isWide: true, content: drawerContent), + ), + const VerticalDivider(width: 1), + Expanded(child: body), + ], + ), + ), + floatingActionButton: floatingActionButton, + bottomNavigationBar: _bottomBar(), + ); + } + + /// Narrow, or wide-but-unpinned: standard transient drawer. Scaffold injects + /// the hamburger into the app bar automatically. + Widget _buildTransient(BuildContext context, bool isWide) { + return Scaffold( + appBar: appBar, + drawer: Drawer( + width: _drawerWidth, + child: _NavPanel(isWide: isWide, content: drawerContent), + ), + body: body, + floatingActionButton: floatingActionButton, + bottomNavigationBar: _bottomBar(), + ); + } +} + +/// Drawer contents. The pin control appears only on wide layouts, where +/// docking the panel open is useful. +class _NavPanel extends StatelessWidget { + final bool isWide; + final Widget? content; + + const _NavPanel({required this.isWide, this.content}); + + @override + Widget build(BuildContext context) { + final uiState = context.watch(); + final pinned = uiState.navDrawerPinned; + + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isWide) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + MaterialLocalizations.of(context).drawerLabel, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + icon: Icon( + pinned ? Icons.push_pin : Icons.push_pin_outlined, + ), + isSelected: pinned, + tooltip: pinned ? 'Unpin panel' : 'Pin panel open', + onPressed: () => uiState.setNavDrawerPinned(!pinned), + ), + ], + ), + ), + if (isWide) const Divider(height: 1), + Expanded(child: content ?? const SizedBox.shrink()), + ], + ), + ); + } +}