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()), + ], + ), + ); + } +}