From 6ccbc71c208c8b91131d4fd00664f44e425c5770 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 19 Jul 2026 23:53:12 -0400 Subject: [PATCH] feat(#290): move Disconnect and Settings into the panel footer Epic C, rescoped. The original plan was to eliminate the overflow menu and move everything into the drawer. An inventory showed that premise was wrong: the ellipsis is six different menus sharing an icon, and most of what they hold is screen-level (force flood mode, path management, delete all discovered). Those cannot live in a global nav panel, which has no notion of which contact or channel is meant. Only Disconnect and Settings are app-level, which is exactly why they were duplicated across three screens. Those move; everything else stays put. - AppShell gains optional onDisconnect / onSettings, rendered as a pinned footer in the nav panel. Disconnect keeps the error colour it had as a red menu entry, since it drops the radio connection. - Channels, Contacts and Map pass both and drop those two menu entries. - Map's overflow menu is removed entirely, having nothing left. - Channels keeps its menu only for Manage Communities, which was already conditional on having joined a community. - Contacts keeps its menu for Discovered contacts. - No detail screen menu is touched. Pinned on a wide screen, Disconnect and Settings are now visible without opening anything. flutter analyze clean, dart format clean, 469 tests pass. Not yet run on hardware. --- lib/screens/channels_screen.dart | 45 ++++++------------ lib/screens/contacts_screen.dart | 32 +++---------- lib/screens/map_screen.dart | 35 ++------------ lib/widgets/app_shell.dart | 79 ++++++++++++++++++++++++++++++-- 4 files changed, 101 insertions(+), 90 deletions(-) diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index f7deb8b..06c514e 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -109,24 +109,22 @@ class _ChannelsScreenState extends State drawerContent: ChannelDrawerList( onChannelSelected: (channel) => _openChannel(context, channel), ), + onDisconnect: () => _disconnect(context), + onSettings: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), appBar: AppBar( title: AppBarTitle(context.l10n.channels_title), centerTitle: true, bottom: const SyncProgressAppBarBottom(), actions: [ - PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.logout, color: Colors.red), - const SizedBox(width: 8), - Text(context.l10n.common_disconnect), - ], - ), - onTap: () => _disconnect(context), - ), - if (_communities.isNotEmpty) + // Disconnect and Settings moved to the panel footer (#290); only + // the screen-level Communities entry remains, and it already only + // appears once a community has been joined. + if (_communities.isNotEmpty) + PopupMenuButton( + itemBuilder: (context) => [ PopupMenuItem( child: Row( children: [ @@ -137,24 +135,9 @@ class _ChannelsScreenState extends State ), onTap: () => _showManageCommunitiesDialog(context), ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.settings), - const SizedBox(width: 8), - Text(context.l10n.settings_title), - ], - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsScreen(), - ), - ), - ), - ], - icon: const Icon(Icons.more_vert), - ), + ], + icon: const Icon(Icons.more_vert), + ), ], ), body: RefreshIndicator( diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 87fc7e4..7d2603b 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -326,6 +326,11 @@ class _ContactsScreenState extends State contactsUnreadCount: connector.getTotalContactsUnreadCount(), channelsUnreadCount: connector.getTotalChannelsUnreadCount(), drawerContent: const ContactFilterRail(), + onDisconnect: () => _disconnect(context, connector), + onSettings: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), appBar: AppBar( title: AppBarTitle(context.l10n.contacts_title), bottom: const SyncProgressAppBarBottom(), @@ -387,18 +392,10 @@ class _ContactsScreenState extends State ], icon: const Icon(Icons.connect_without_contact), ), + // Disconnect and Settings moved to the panel footer (#290). + // Discovered contacts is screen-level and stays here. PopupMenuButton( itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.logout, color: Colors.red), - const SizedBox(width: 8), - Text(context.l10n.common_disconnect), - ], - ), - onTap: () => _disconnect(context, connector), - ), PopupMenuItem( child: Row( children: [ @@ -414,21 +411,6 @@ class _ContactsScreenState extends State ), ), ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.settings), - const SizedBox(width: 8), - Text(context.l10n.settings_title), - ], - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsScreen(), - ), - ), - ), ], icon: const Icon(Icons.more_vert), ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1444f5a..7cb7d27 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -418,6 +418,11 @@ class _MapScreenState extends State { _handleQuickSwitch(index, context), contactsUnreadCount: connector.getTotalContactsUnreadCount(), channelsUnreadCount: connector.getTotalChannelsUnreadCount(), + onDisconnect: () => _disconnect(context, connector), + onSettings: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), appBar: AppBar( title: AppBarTitle(context.l10n.map_title), centerTitle: true, @@ -472,36 +477,6 @@ class _MapScreenState extends State { }, tooltip: context.l10n.map_lineOfSight, ), - PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.logout, color: Colors.red), - const SizedBox(width: 8), - Text(context.l10n.common_disconnect), - ], - ), - onTap: () => _disconnect(context, connector), - ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.settings), - const SizedBox(width: 8), - Text(context.l10n.settings_title), - ], - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsScreen(), - ), - ), - ), - ], - icon: const Icon(Icons.more_vert), - ), ], ), body: Stack( diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index cfc8ab9..329cb59 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../l10n/l10n.dart'; import '../services/ui_view_state_service.dart'; import 'quick_switch_bar.dart'; @@ -36,6 +37,12 @@ class AppShell extends StatelessWidget { /// List content for the active view. Null renders an empty drawer body. final Widget? drawerContent; + /// App-level actions, pinned to the bottom of the panel. These are not + /// contextual, which is why they were duplicated in three screens' overflow + /// menus before. Screen-level actions stay in their own overflow menu. + final VoidCallback? onDisconnect; + final VoidCallback? onSettings; + const AppShell({ super.key, required this.body, @@ -45,6 +52,8 @@ class AppShell extends StatelessWidget { this.appBarBuilder, this.floatingActionButton, this.drawerContent, + this.onDisconnect, + this.onSettings, this.contactsUnreadCount = 0, this.channelsUnreadCount = 0, }); @@ -95,7 +104,12 @@ class AppShell extends StatelessWidget { children: [ SizedBox( width: _drawerWidth, - child: _NavPanel(isWide: true, content: drawerContent), + child: _NavPanel( + isWide: true, + content: drawerContent, + onDisconnect: onDisconnect, + onSettings: onSettings, + ), ), const VerticalDivider(width: 1), Expanded(child: body), @@ -114,7 +128,12 @@ class AppShell extends StatelessWidget { appBar: _appBar(context, false), drawer: Drawer( width: _drawerWidth, - child: _NavPanel(isWide: isWide, content: drawerContent), + child: _NavPanel( + isWide: isWide, + content: drawerContent, + onDisconnect: onDisconnect, + onSettings: onSettings, + ), ), body: body, floatingActionButton: floatingActionButton, @@ -128,8 +147,15 @@ class AppShell extends StatelessWidget { class _NavPanel extends StatelessWidget { final bool isWide; final Widget? content; - - const _NavPanel({required this.isWide, this.content}); + final VoidCallback? onDisconnect; + final VoidCallback? onSettings; + + const _NavPanel({ + required this.isWide, + this.content, + this.onDisconnect, + this.onSettings, + }); @override Widget build(BuildContext context) { @@ -164,6 +190,51 @@ class _NavPanel extends StatelessWidget { ), if (isWide) const Divider(height: 1), Expanded(child: content ?? const SizedBox.shrink()), + if (onDisconnect != null || onSettings != null) ...[ + const Divider(height: 1), + _Footer(onDisconnect: onDisconnect, onSettings: onSettings), + ], + ], + ), + ); + } +} + +/// App-level actions pinned to the bottom of the panel. +class _Footer extends StatelessWidget { + final VoidCallback? onDisconnect; + final VoidCallback? onSettings; + + const _Footer({this.onDisconnect, this.onSettings}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 8), + child: Row( + children: [ + if (onDisconnect != null) + Expanded( + child: TextButton.icon( + // Kept visually distinct: this drops the radio connection, + // and it was a red menu entry before the move. + style: TextButton.styleFrom(foregroundColor: colors.error), + icon: const Icon(Icons.logout, size: 18), + label: Text(l10n.common_disconnect), + onPressed: onDisconnect, + ), + ), + if (onSettings != null) + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.settings, size: 18), + label: Text(l10n.settings_title), + onPressed: onSettings, + ), + ), ], ), );