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.
pull/324/head
Strycher 4 days ago
parent 006c365d25
commit 431b93f0fd

@ -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<ChannelsScreen>
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<ChannelsScreen>
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(),
),
),
),
);
}

@ -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<ContactsScreen>
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<ContactsScreen>
],
),
body: _buildContactsBody(context, connector),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
selectedIndex: 0,
onDestinationSelected: (index) =>
_handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
),
),
),
);
}

@ -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<LineOfSightMapScreen> {
_showMarkerLabels = initialZoom >= _labelZoomThreshold;
}
return Scaffold(
return AppShell(
selectedIndex: 2,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalContactsUnreadCount(),
channelsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalChannelsUnreadCount(),
appBar: AppBar(
title: AppBarTitle(widget.title),
centerTitle: true,
@ -534,19 +542,6 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
: 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<MeshCoreConnector>()
.getTotalContactsUnreadCount(),
channelsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalChannelsUnreadCount(),
),
),
);
}

@ -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<MapScreen> {
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<MapScreen> {
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,

@ -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<void> 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));
}
}

@ -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<int> 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<UiViewStateService>().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<UiViewStateService>();
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()),
],
),
);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.