From 6ccbc71c208c8b91131d4fd00664f44e425c5770 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 19 Jul 2026 23:53:12 -0400 Subject: [PATCH 1/6] 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, + ), + ), ], ), ); From 4ece02ceff3b83e2f4cae5f286dd0b8a8ac9ca0a Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 00:13:27 -0400 Subject: [PATCH 2/6] feat(#291): map layer toggles in the nav panel Epic D. The Map view has no list, so its nav panel was empty. It now carries the map's own layer controls. Those six toggles (chat nodes, repeaters, other nodes, discovery contacts, guessed locations, overlaps) previously existed only in a modal behind the floating button, so filtering the map meant covering the map with a dialog to do it. In the panel, and especially pinned on a wide screen, they stay visible and the map updates underneath as they are flipped. Both surfaces read and write the same AppSettings, so a change in one is reflected in the other. The floating-button dialog is deliberately left in place: on a phone it is fewer taps than opening the drawer, and the panel is the pinned-desktop path. If that duplication is unwanted, removing the dialog is a one-line follow-up. No new settings or storage; the existing setters were reused unchanged. flutter analyze clean, dart format clean, 469 tests pass. Not yet run on hardware. --- lib/screens/map_screen.dart | 2 + lib/widgets/map_layer_panel.dart | 99 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 lib/widgets/map_layer_panel.dart diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 7cb7d27..c902cb6 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -24,6 +24,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; import '../widgets/app_shell.dart'; +import '../widgets/map_layer_panel.dart'; import '../widgets/sync_progress_overlay.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; @@ -418,6 +419,7 @@ class _MapScreenState extends State { _handleQuickSwitch(index, context), contactsUnreadCount: connector.getTotalContactsUnreadCount(), channelsUnreadCount: connector.getTotalChannelsUnreadCount(), + drawerContent: const MapLayerPanel(), onDisconnect: () => _disconnect(context, connector), onSettings: () => Navigator.push( context, diff --git a/lib/widgets/map_layer_panel.dart b/lib/widgets/map_layer_panel.dart new file mode 100644 index 0000000..13df116 --- /dev/null +++ b/lib/widgets/map_layer_panel.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/l10n.dart'; +import '../services/app_settings_service.dart'; + +/// Map layer toggles for the nav panel. +/// +/// The Map view has no list to show, so its panel carries the layer controls +/// that otherwise live in a modal behind the floating button. Pinned on a wide +/// screen these stay visible and the map updates underneath as they are +/// flipped, instead of a dialog covering the thing being filtered. +/// +/// The same settings back both surfaces, so a change here shows in the dialog +/// and vice versa. +class MapLayerPanel extends StatelessWidget { + const MapLayerPanel({super.key}); + + @override + Widget build(BuildContext context) { + final service = context.watch(); + final settings = service.settings; + final l10n = context.l10n; + final theme = Theme.of(context); + + return ListView( + padding: EdgeInsets.zero, + children: [ + _sectionHeader(theme, l10n.map_nodeTypes), + _toggle( + label: l10n.map_chatNodes, + icon: Icons.person_outline, + value: settings.mapShowChatNodes, + onChanged: service.setMapShowChatNodes, + ), + _toggle( + label: l10n.map_repeaters, + icon: Icons.cell_tower, + value: settings.mapShowRepeaters, + onChanged: service.setMapShowRepeaters, + ), + _toggle( + label: l10n.map_otherNodes, + icon: Icons.help_outline, + value: settings.mapShowOtherNodes, + onChanged: service.setMapShowOtherNodes, + ), + const Divider(height: 1), + _sectionHeader(theme, l10n.map_filterNodes), + _toggle( + label: l10n.map_showDiscoveryContacts, + icon: Icons.person_search, + value: settings.mapShowDiscoveryContacts, + onChanged: service.setMapShowDiscoveryContacts, + ), + _toggle( + label: l10n.map_showGuessedLocations, + icon: Icons.location_searching, + value: settings.mapShowGuessedLocations, + onChanged: service.setMapShowGuessedLocations, + ), + _toggle( + label: l10n.map_showOverlaps, + icon: Icons.layers, + value: settings.mapShowOverlaps, + onChanged: service.setMapShowOverlaps, + ), + ], + ); + } + + Widget _sectionHeader(ThemeData theme, String label) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _toggle({ + required String label, + required IconData icon, + required bool value, + required ValueChanged onChanged, + }) { + return SwitchListTile( + dense: true, + secondary: Icon(icon, size: 20), + title: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis), + value: value, + onChanged: onChanged, + ); + } +} From 9aaebce77c868df48efdcd7d8c9bc9e91b4c65da Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 00:37:51 -0400 Subject: [PATCH 3/6] fix(#291): map name toggle, panel footer everywhere, and back-button behavior Owner hardware test on an S25 FE and Windows turned up five issues. Three of them shared one root cause. **Map: always show names.** New mapAlwaysShowNames setting, toggled from the map panel. Names were shown only past zoom 14; the override wins at any zoom. Evaluated at build rather than only on camera movement, so flipping the switch repaints immediately instead of waiting for the next pan. **Panel footer on every screen.** Disconnect and Settings now also appear inside a channel. The footer is app-level, so it belongs anywhere the panel is, not just the primary views. **Back button (three reported symptoms, one cause).** Channels, Contacts and Map each wrapped themselves in `PopScope(canPop: !isConnected)`, so while connected the system back press was swallowed whole. That explains all three: an open drawer would not close, Android would not background the app, and on Windows the pinned layout has no Scaffold drawer, so the app bar auto-implied a back arrow whose press PopScope then discarded - a button that visibly did nothing. AppShell now owns back handling for every screen that uses it, in order: close an open drawer, else pop the route (a pushed chat returns to its list), else hand back to the OS via SystemNavigator.pop() so Android returns to the home screen or the previous app. The three per-screen PopScope wrappers are removed. AppShell becomes stateful to hold the scaffold key this needs. flutter analyze clean, dart format clean, 469 tests pass. Not yet re-tested on hardware. --- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_bg.dart | 3 + lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_es.dart | 3 + lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_hu.dart | 3 + lib/l10n/app_localizations_it.dart | 3 + lib/l10n/app_localizations_ja.dart | 3 + lib/l10n/app_localizations_ko.dart | 3 + lib/l10n/app_localizations_nl.dart | 3 + lib/l10n/app_localizations_pl.dart | 3 + lib/l10n/app_localizations_pt.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_localizations_sk.dart | 3 + lib/l10n/app_localizations_sl.dart | 3 + lib/l10n/app_localizations_sv.dart | 3 + lib/l10n/app_localizations_uk.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/models/app_settings.dart | 8 + lib/screens/channel_chat_screen.dart | 14 + lib/screens/channels_screen.dart | 423 +++++++++++------------ lib/screens/contacts_screen.dart | 180 +++++----- lib/screens/map_screen.dart | 461 ++++++++++++------------- lib/services/app_settings_service.dart | 4 + lib/widgets/app_shell.dart | 94 +++-- lib/widgets/map_layer_panel.dart | 7 + 28 files changed, 687 insertions(+), 565 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 48c6f31..a7b3413 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -976,6 +976,7 @@ "map_repeaters": "Repeaters", "map_otherNodes": "Other Nodes", "map_showOverlaps": "Repeater Key Overlaps", + "map_alwaysShowNames": "Always show names", "map_keyPrefix": "Key Prefix", "map_filterByKeyPrefix": "Filter by key prefix", "map_publicKeyPrefix": "Public key prefix", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2c425e2..0ad7394 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3412,6 +3412,12 @@ abstract class AppLocalizations { /// **'Repeater Key Overlaps'** String get map_showOverlaps; + /// No description provided for @map_alwaysShowNames. + /// + /// In en, this message translates to: + /// **'Always show names'** + String get map_alwaysShowNames; + /// No description provided for @map_keyPrefix. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index ee62916..a379cc6 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1890,6 +1890,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_showOverlaps => 'Покриване на ключа на повтаряча'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Префикс на ключа'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 92c1102..b60bea3 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1887,6 +1887,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get map_showOverlaps => 'Überlappungen der Repeater-Taste'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Schlüsselpräfix'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7989c98..db7573f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1854,6 +1854,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_showOverlaps => 'Repeater Key Overlaps'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Key Prefix'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index df55c77..9253a00 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1885,6 +1885,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get map_showOverlaps => 'Superposiciones de tecla repetidora'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefijo de clave'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e02d168..19bd894 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1895,6 +1895,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get map_showOverlaps => 'Chevauchement de la touche répétitive'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Préfixe clé'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 3dabd04..98933ef 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1896,6 +1896,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Kulcsfontosságú előtag'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index c4ad345..b0197a8 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1888,6 +1888,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefisso Chiave'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 1c2edbf..b244d39 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1811,6 +1811,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get map_showOverlaps => 'リピーターキーの重複'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => '主要なプレフィックス'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6403027..90228a2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1807,6 +1807,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get map_showOverlaps => '반복 키 중복'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => '핵심 접두사'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 8e34076..440530b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1874,6 +1874,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get map_showOverlaps => 'Herhalingssleutel overlapt'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefix sleutel'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 2a034a7..be65e06 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1900,6 +1900,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get map_showOverlaps => 'Nakładające się klucze przekaźników'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefiks klucza'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b0851fe..efcfad0 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1885,6 +1885,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get map_showOverlaps => 'Sobreposições da Chave Repeater'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefixo Chave'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5ae0d11..63b5679 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1889,6 +1889,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_showOverlaps => 'Перекрытия ключа повтора'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Префикс ключа'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 0b885ac..be75989 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1876,6 +1876,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get map_showOverlaps => 'Prekrývanie opakovača kľúča'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Päťciferné predpona'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index fa6c120..4b9518c 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1871,6 +1871,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Predpona ključa'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a4e67f6..ab1047d 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1864,6 +1864,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get map_showOverlaps => 'Repeater-nyckelöverlappningar'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Nyckelprefix'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 00aeeb1..ee32259 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1884,6 +1884,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_showOverlaps => 'Перекриття ключів ретрансляторів'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Префікс ключа'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 051db0e..7ae7266 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1778,6 +1778,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_showOverlaps => '重复键重叠'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => '关键字前缀'; diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 005197f..782e78a 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -112,6 +112,9 @@ class AppSettings { final bool mapShowChatNodes; final bool mapShowOtherNodes; final bool mapShowOverlaps; + + /// Show node names at any zoom, instead of only past the zoom threshold. + final bool mapAlwaysShowNames; final double mapTimeFilterHours; // 0 = all time final bool mapKeyPrefixEnabled; final String mapKeyPrefix; @@ -188,6 +191,7 @@ class AppSettings { this.mapShowChatNodes = true, this.mapShowOtherNodes = true, this.mapShowOverlaps = false, + this.mapAlwaysShowNames = false, this.mapTimeFilterHours = 0, // Default to all time this.mapKeyPrefixEnabled = false, this.mapKeyPrefix = '', @@ -252,6 +256,7 @@ class AppSettings { 'map_show_chat_nodes': mapShowChatNodes, 'map_show_other_nodes': mapShowOtherNodes, 'map_show_overlaps': mapShowOverlaps, + 'map_always_show_names': mapAlwaysShowNames, 'map_time_filter_hours': mapTimeFilterHours, 'map_key_prefix_enabled': mapKeyPrefixEnabled, 'map_key_prefix': mapKeyPrefix, @@ -338,6 +343,7 @@ class AppSettings { mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true, mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true, mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false, + mapAlwaysShowNames: json['map_always_show_names'] as bool? ?? false, mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0, mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false, @@ -462,6 +468,7 @@ class AppSettings { bool? mapShowChatNodes, bool? mapShowOtherNodes, bool? mapShowOverlaps, + bool? mapAlwaysShowNames, double? mapTimeFilterHours, bool? mapKeyPrefixEnabled, String? mapKeyPrefix, @@ -510,6 +517,7 @@ class AppSettings { mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes, mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes, mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps, + mapAlwaysShowNames: mapAlwaysShowNames ?? this.mapAlwaysShowNames, mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours, mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled, mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 68cc7dd..6917817 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -29,6 +29,8 @@ import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; +import 'settings_screen.dart'; +import '../utils/dialog_utils.dart'; import '../widgets/app_shell.dart'; import '../widgets/channel_drawer_list.dart'; import '../widgets/mention_autocomplete.dart'; @@ -287,6 +289,11 @@ class _ChannelChatScreenState extends State { ); } + Future _disconnect(BuildContext context) async { + final connector = context.read(); + await showDisconnectDialog(context, connector); + } + /// Bottom-bar navigation out of an open channel. /// /// Tapping Channels returns to the channel list. Tapping Contacts or Map @@ -351,6 +358,13 @@ class _ChannelChatScreenState extends State { currentChannelIndex: _currentChannel.index, onChannelSelected: _switchChannel, ), + // Present here too: the footer is app-level, so it belongs on every + // screen carrying the panel, not just the primary views. + onDisconnect: () => _disconnect(context), + onSettings: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), appBarBuilder: (context, pinned) => AppBar( // Pinned: the panel is already docked, so no hamburger is needed. // Unpinned: this is a pushed route, so Scaffold would put a back arrow diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 06c514e..eedfd71 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -97,234 +97,229 @@ class _ChannelsScreenState extends State return const SizedBox.shrink(); } - final allowBack = !connector.isConnected; - - return PopScope( - canPop: allowBack, - child: AppShell( - selectedIndex: 1, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - 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: [ - // 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: [ - const Icon(Icons.groups), - const SizedBox(width: 8), - Text(context.l10n.community_manageCommunities), - ], - ), - onTap: () => _showManageCommunitiesDialog(context), + return AppShell( + selectedIndex: 1, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), + 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: [ + // 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: [ + const Icon(Icons.groups), + const SizedBox(width: 8), + Text(context.l10n.community_manageCommunities), + ], ), - ], - icon: const Icon(Icons.more_vert), - ), - ], - ), - body: RefreshIndicator( - onRefresh: () async { - await context.read().getChannels(force: true); - }, - child: () { - final channels = connector.channels; - final waitingForFirstChannel = - connector.isLoadingChannels && channels.isEmpty; - - // Only block the list while the first channel is actively loading. - // If the initial sync aborts, show cached/partial channels instead - // of trapping the user behind an idle spinner. - if (waitingForFirstChannel) { - return const Center(child: CircularProgressIndicator()); - } + onTap: () => _showManageCommunitiesDialog(context), + ), + ], + icon: const Icon(Icons.more_vert), + ), + ], + ), + body: RefreshIndicator( + onRefresh: () async { + await context.read().getChannels(force: true); + }, + child: () { + final channels = connector.channels; + final waitingForFirstChannel = + connector.isLoadingChannels && channels.isEmpty; + + // Only block the list while the first channel is actively loading. + // If the initial sync aborts, show cached/partial channels instead + // of trapping the user behind an idle spinner. + if (waitingForFirstChannel) { + return const Center(child: CircularProgressIndicator()); + } - if (channels.isEmpty) { - return ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 200, - child: EmptyState( - icon: Icons.tag, - title: context.l10n.channels_noChannelsConfigured, - action: FilledButton.icon( - onPressed: () => _addPublicChannel(context, connector), - icon: const Icon(Icons.public), - label: Text(context.l10n.channels_addPublicChannel), - ), + if (channels.isEmpty) { + return ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height - 200, + child: EmptyState( + icon: Icons.tag, + title: context.l10n.channels_noChannelsConfigured, + action: FilledButton.icon( + onPressed: () => _addPublicChannel(context, connector), + icon: const Icon(Icons.public), + label: Text(context.l10n.channels_addPublicChannel), ), ), - ], - ); - } - - final filteredChannels = _filterAndSortChannels( - channels, - connector, - viewState, + ), + ], ); + } - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.channels_searchChannels, - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (viewState.channelsSearchText.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchDebounce?.cancel(); - _searchDebounce = null; - _searchController.clear(); - context - .read() - .setChannelsSearchText(''); - }, - ), - _buildFilterButton(viewState), - ], - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + final filteredChannels = _filterAndSortChannels( + channels, + connector, + viewState, + ); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.channels_searchChannels, + prefixIcon: const Icon(Icons.search), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (viewState.channelsSearchText.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchDebounce?.cancel(); + _searchDebounce = null; + _searchController.clear(); + context + .read() + .setChannelsSearchText(''); + }, + ), + _buildFilterButton(viewState), + ], + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - onChanged: (value) { - _searchDebounce?.cancel(); - _searchDebounce = Timer( - const Duration(milliseconds: 300), - () { - if (!mounted) return; - context - .read() - .setChannelsSearchText(value); - }, - ); - }, ), + onChanged: (value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 300), + () { + if (!mounted) return; + context + .read() + .setChannelsSearchText(value); + }, + ); + }, ), - Expanded( - child: filteredChannels.isEmpty - ? ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 300, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - context.l10n.channels_noChannelsFound, - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), + ), + Expanded( + child: filteredChannels.isEmpty + ? ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height - 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + context.l10n.channels_noChannelsFound, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], ), - ], - ), + ), + ], ), ), - ], - ) - : (viewState.channelsSortOption == - ChannelSortOption.manual && - viewState.channelsSearchText.isEmpty) - ? ReorderableListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, - ), - buildDefaultDragHandles: false, - itemCount: filteredChannels.length, - onReorderItem: (oldIndex, newIndex) { - // onReorderItem already adjusts newIndex after the - // removed item, unlike the deprecated onReorder. - final reordered = List.from( - filteredChannels, - ); - final item = reordered.removeAt(oldIndex); - reordered.insert(newIndex, item); - unawaited( - connector.setChannelOrder( - reordered.map((c) => c.index).toList(), - ), - ); - }, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - showDragHandle: true, - dragIndex: index, - ); - }, - ) - : ListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, ), - itemCount: filteredChannels.length, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - ); - }, + ], + ) + : (viewState.channelsSortOption == + ChannelSortOption.manual && + viewState.channelsSearchText.isEmpty) + ? ReorderableListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, ), - ), - ], - ); - }(), - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showAddChannelDialog(context), - tooltip: context.l10n.channels_addChannel, - child: const Icon(Icons.add), - ), + buildDefaultDragHandles: false, + itemCount: filteredChannels.length, + onReorderItem: (oldIndex, newIndex) { + // onReorderItem already adjusts newIndex after the + // removed item, unlike the deprecated onReorder. + final reordered = List.from( + filteredChannels, + ); + final item = reordered.removeAt(oldIndex); + reordered.insert(newIndex, item); + unawaited( + connector.setChannelOrder( + reordered.map((c) => c.index).toList(), + ), + ); + }, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + showDragHandle: true, + dragIndex: index, + ); + }, + ) + : ListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, + ), + itemCount: filteredChannels.length, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + ); + }, + ), + ), + ], + ); + }(), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddChannelDialog(context), + tooltip: context.l10n.channels_addChannel, + child: const Icon(Icons.add), ), ); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 7d2603b..ce32f4c 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -317,107 +317,103 @@ class _ContactsScreenState extends State return const SizedBox.shrink(); } - final allowBack = !connector.isConnected; - return PopScope( - canPop: allowBack, - child: AppShell( - selectedIndex: 0, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), - 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(), - actions: [ - PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.connect_without_contact), - const SizedBox(width: 8), - Text(context.l10n.contacts_zeroHopAdvert), - ], - ), - onTap: () => { - connector.sendSelfAdvert(flood: false), - showDismissibleSnackBar( - context, - content: Text(context.l10n.settings_advertisementSent), - ), - }, + return AppShell( + selectedIndex: 0, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + 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(), + actions: [ + PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.connect_without_contact), + const SizedBox(width: 8), + Text(context.l10n.contacts_zeroHopAdvert), + ], ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.cell_tower), - const SizedBox(width: 8), - Text(context.l10n.contacts_floodAdvert), - ], + onTap: () => { + connector.sendSelfAdvert(flood: false), + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_advertisementSent), ), - onTap: () => { - connector.sendSelfAdvert(flood: true), - showDismissibleSnackBar( - context, - content: Text(context.l10n.settings_advertisementSent), - ), - }, + }, + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.cell_tower), + const SizedBox(width: 8), + Text(context.l10n.contacts_floodAdvert), + ], ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.copy), - const SizedBox(width: 8), - Text(context.l10n.contacts_copyAdvertToClipboard), - ], + onTap: () => { + connector.sendSelfAdvert(flood: true), + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_advertisementSent), ), - onTap: () => _contactExport(Uint8List.fromList([])), + }, + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.copy), + const SizedBox(width: 8), + Text(context.l10n.contacts_copyAdvertToClipboard), + ], ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.paste), - const SizedBox(width: 8), - Text(context.l10n.contacts_addContactFromClipboard), - ], - ), - onTap: () => _contactImport(), + onTap: () => _contactExport(Uint8List.fromList([])), + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.paste), + const SizedBox(width: 8), + Text(context.l10n.contacts_addContactFromClipboard), + ], ), - ], - 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.person_add_rounded), - const SizedBox(width: 8), - Text(context.l10n.discoveredContacts_Title), - ], - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DiscoveryScreen(), - ), + onTap: () => _contactImport(), + ), + ], + 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.person_add_rounded), + const SizedBox(width: 8), + Text(context.l10n.discoveredContacts_Title), + ], + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DiscoveryScreen(), ), ), - ], - icon: const Icon(Icons.more_vert), - ), - ], - ), - body: _buildContactsBody(context, connector), + ), + ], + icon: const Icon(Icons.more_vert), + ), + ], ), + body: _buildContactsBody(context, connector), ); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index c902cb6..2b9ade1 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -399,7 +399,8 @@ class _MapScreenState extends State { // Re center map after removed markers have loaded if (!_hasInitializedMap && _removedMarkersLoaded) { _hasInitializedMap = true; - _showNodeLabels = initialZoom >= _labelZoomThreshold; + _showNodeLabels = + settings.mapAlwaysShowNames || initialZoom >= _labelZoomThreshold; if (hasMapContent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -409,257 +410,253 @@ class _MapScreenState extends State { } } - final allowBack = !connector.isConnected; - - return PopScope( - canPop: allowBack, - child: AppShell( - selectedIndex: 2, - onDestinationSelected: (index) => - _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - drawerContent: const MapLayerPanel(), - onDisconnect: () => _disconnect(context, connector), - onSettings: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), - ), - appBar: AppBar( - title: AppBarTitle(context.l10n.map_title), - centerTitle: true, - bottom: const SyncProgressAppBarBottom(), - actions: [ - if (!_isBuildingPathTrace) - IconButton( - icon: const Icon(Icons.radar), - onPressed: () => _startPath( - LatLng(connector.selfLatitude!, connector.selfLongitude!), - ), - tooltip: context.l10n.contacts_pathTrace, + return AppShell( + selectedIndex: 2, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), + drawerContent: const MapLayerPanel(), + onDisconnect: () => _disconnect(context, connector), + onSettings: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), + appBar: AppBar( + title: AppBarTitle(context.l10n.map_title), + centerTitle: true, + bottom: const SyncProgressAppBarBottom(), + actions: [ + if (!_isBuildingPathTrace) + IconButton( + icon: const Icon(Icons.radar), + onPressed: () => _startPath( + LatLng(connector.selfLatitude!, connector.selfLongitude!), ), - if (!_isBuildingPathTrace) - IconButton( - icon: const LosIcon(), - onPressed: () { - final candidates = []; - if (connector.selfLatitude != null && - connector.selfLongitude != null) { - candidates.add( - LineOfSightEndpoint( - label: context.l10n.pathTrace_you, - point: LatLng( - connector.selfLatitude!, - connector.selfLongitude!, - ), - color: Colors.teal, - icon: Icons.person_pin_circle, - ), - ); - } - for (final c in contactsWithLocation) { - candidates.add( - LineOfSightEndpoint( - label: c.name, - point: LatLng(c.latitude!, c.longitude!), - color: _getNodeColor(c.type), - icon: _getNodeIcon(c.type), - ), - ); - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LineOfSightMapScreen( - title: context.l10n.map_losScreenTitle, - candidates: candidates, + tooltip: context.l10n.contacts_pathTrace, + ), + if (!_isBuildingPathTrace) + IconButton( + icon: const LosIcon(), + onPressed: () { + final candidates = []; + if (connector.selfLatitude != null && + connector.selfLongitude != null) { + candidates.add( + LineOfSightEndpoint( + label: context.l10n.pathTrace_you, + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, ), + color: Colors.teal, + icon: Icons.person_pin_circle, ), ); - }, - tooltip: context.l10n.map_lineOfSight, + } + for (final c in contactsWithLocation) { + candidates.add( + LineOfSightEndpoint( + label: c.name, + point: LatLng(c.latitude!, c.longitude!), + color: _getNodeColor(c.type), + icon: _getNodeIcon(c.type), + ), + ); + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LineOfSightMapScreen( + title: context.l10n.map_losScreenTitle, + candidates: candidates, + ), + ), + ); + }, + tooltip: context.l10n.map_lineOfSight, + ), + ], + ), + body: Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: center, + initialZoom: initialZoom, + minZoom: _mapMinZoom, + maxZoom: _mapMaxZoom, + interactionOptions: InteractionOptions( + flags: ~InteractiveFlag.rotate, + scrollWheelVelocity: isDesktop ? 0.012 : 0.005, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), + keyboardOptions: isDesktop + ? const KeyboardOptions( + enableArrowKeysPanning: true, + enableWASDPanning: true, + enableRFZooming: true, + ) + : const KeyboardOptions.disabled(), ), - ], - ), - body: Stack( - children: [ - FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: center, - initialZoom: initialZoom, - minZoom: _mapMinZoom, - maxZoom: _mapMaxZoom, - interactionOptions: InteractionOptions( - flags: ~InteractiveFlag.rotate, - scrollWheelVelocity: isDesktop ? 0.012 : 0.005, - cursorKeyboardRotationOptions: - CursorKeyboardRotationOptions.disabled(), - keyboardOptions: isDesktop - ? const KeyboardOptions( - enableArrowKeysPanning: true, - enableWASDPanning: true, - enableRFZooming: true, - ) - : const KeyboardOptions.disabled(), - ), - onTap: (_, latLng) { - if (_isSelectingPoi) { - setState(() { - _isSelectingPoi = false; - }); - _shareMarker( - context: context, - connector: connector, - position: latLng, - defaultLabel: context.l10n.map_pointOfInterest, - flags: 'poi', - ); - } - }, - onLongPress: (_, latLng) { - if (_isSelectingPoi) { - setState(() { - _isSelectingPoi = false; - }); - _shareMarker( - context: context, - connector: connector, - position: latLng, - defaultLabel: context.l10n.map_pointOfInterest, - flags: 'poi', - ); - return; - } - _showShareMarkerAtPositionSheet( + onTap: (_, latLng) { + if (_isSelectingPoi) { + setState(() { + _isSelectingPoi = false; + }); + _shareMarker( context: context, connector: connector, position: latLng, + defaultLabel: context.l10n.map_pointOfInterest, + flags: 'poi', ); - }, - onPositionChanged: (camera, hasGesture) { - final shouldShow = camera.zoom >= _labelZoomThreshold; - if (shouldShow != _showNodeLabels && mounted) { - setState(() { - _showNodeLabels = shouldShow; - }); - } - }, + } + }, + onLongPress: (_, latLng) { + if (_isSelectingPoi) { + setState(() { + _isSelectingPoi = false; + }); + _shareMarker( + context: context, + connector: connector, + position: latLng, + defaultLabel: context.l10n.map_pointOfInterest, + flags: 'poi', + ); + return; + } + _showShareMarkerAtPositionSheet( + context: context, + connector: connector, + position: latLng, + ); + }, + onPositionChanged: (camera, hasGesture) { + final shouldShow = + settings.mapAlwaysShowNames || + camera.zoom >= _labelZoomThreshold; + if (shouldShow != _showNodeLabels && mounted) { + setState(() { + _showNodeLabels = shouldShow; + }); + } + }, + ), + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: + MapTileCacheService.userAgentPackageName, + maxZoom: 19, ), - children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, - ), - if (_polylines.isNotEmpty && _isBuildingPathTrace) - PolylineLayer(polylines: _polylines), - if (sharedMarkerPolylines.isNotEmpty) - PolylineLayer(polylines: sharedMarkerPolylines), - MarkerLayer( - markers: [ - if (highlightPosition != null) - Marker( - point: highlightPosition, - width: 40, - height: 40, - child: IgnorePointer( - child: Icon( - Icons.location_on_outlined, - color: Colors.red[600], - size: 34, - ), + if (_polylines.isNotEmpty && _isBuildingPathTrace) + PolylineLayer(polylines: _polylines), + if (sharedMarkerPolylines.isNotEmpty) + PolylineLayer(polylines: sharedMarkerPolylines), + MarkerLayer( + markers: [ + if (highlightPosition != null) + Marker( + point: highlightPosition, + width: 40, + height: 40, + child: IgnorePointer( + child: Icon( + Icons.location_on_outlined, + color: Colors.red[600], + size: 34, ), ), - if (!settings.mapShowOverlaps) - ..._buildGuessedMarker( - guessedLocations, - showLabels: _showNodeLabels, - ), - ..._buildMarkers( - contactsWithLocation, - settings, - showLabels: _showNodeLabels, ), - ...sharedMarkers.map(_buildSharedMarker), - if (connector.selfLatitude != null && - connector.selfLongitude != null) - Marker( - point: LatLng( - connector.selfLatitude!, - connector.selfLongitude!, - ), - width: 40, - height: 40, - child: IgnorePointer( - ignoring: true, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.teal, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues( - alpha: 0.3, - ), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: const Icon( - Icons.person_pin_circle, + if (!settings.mapShowOverlaps) + ..._buildGuessedMarker( + guessedLocations, + showLabels: + settings.mapAlwaysShowNames || _showNodeLabels, + ), + ..._buildMarkers( + contactsWithLocation, + settings, + showLabels: + settings.mapAlwaysShowNames || _showNodeLabels, + ), + ...sharedMarkers.map(_buildSharedMarker), + if (connector.selfLatitude != null && + connector.selfLongitude != null) + Marker( + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, + ), + width: 40, + height: 40, + child: IgnorePointer( + ignoring: true, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.teal, + shape: BoxShape.circle, + border: Border.all( color: Colors.white, - size: 20, + width: 2, ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: const Icon( + Icons.person_pin_circle, + color: Colors.white, + size: 20, ), ), ), - if (_showNodeLabels && - connector.selfLatitude != null && - connector.selfLongitude != null) - _buildNodeLabelMarker( - point: LatLng( - connector.selfLatitude!, - connector.selfLongitude!, - ), - label: context.l10n.pathTrace_you, + ), + if (_showNodeLabels && + connector.selfLatitude != null && + connector.selfLongitude != null) + _buildNodeLabelMarker( + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, ), - ], - ), - ], - ), - if (!_isBuildingPathTrace) - _buildLegend( - contacts, - contactsWithLocation, - settings, - sharedMarkers.length, - guessedLocations.length, - ), - if (isDesktop) - _buildDesktopMapControls( - context, - center: center, - zoom: initialZoom, - hasPathSelector: _isBuildingPathTrace, + label: context.l10n.pathTrace_you, + ), + ], ), - if (_isBuildingPathTrace) _buildPathTraceOverlay(), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showFilterDialog(context, settingsService), - tooltip: context.l10n.map_filterNodes, - child: const Icon(Icons.filter_list), - ), + ], + ), + if (!_isBuildingPathTrace) + _buildLegend( + contacts, + contactsWithLocation, + settings, + sharedMarkers.length, + guessedLocations.length, + ), + if (isDesktop) + _buildDesktopMapControls( + context, + center: center, + zoom: initialZoom, + hasPathSelector: _isBuildingPathTrace, + ), + if (_isBuildingPathTrace) _buildPathTraceOverlay(), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showFilterDialog(context, settingsService), + tooltip: context.l10n.map_filterNodes, + child: const Icon(Icons.filter_list), ), ); }, diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 5e3d8c3..d0ea235 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -76,6 +76,10 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(mapShowOverlaps: value)); } + Future setMapAlwaysShowNames(bool value) async { + await updateSettings(_settings.copyWith(mapAlwaysShowNames: value)); + } + Future setMapTimeFilterHours(double value) async { await updateSettings(_settings.copyWith(mapTimeFilterHours: value)); } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 329cb59..689caa3 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; @@ -12,7 +13,7 @@ import 'quick_switch_bar.dart'; /// 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 { +class AppShell extends StatefulWidget { static const double wideBreakpoint = 720; static const double _drawerWidth = 300; @@ -58,44 +59,82 @@ class AppShell extends StatelessWidget { this.channelsUnreadCount = 0, }); + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + /// System back, in priority order: + /// 1. an open drawer closes, + /// 2. otherwise pop the route (a pushed chat returns to its list), + /// 3. otherwise hand back to the OS, so Android returns to the home + /// screen or the previous app rather than sitting on a dead press. + /// + /// Screens previously did this with `PopScope(canPop: !isConnected)`, which + /// swallowed back entirely while connected: the drawer would not close and + /// the app would never background. + void _handleBack() { + final scaffold = _scaffoldKey.currentState; + if (scaffold?.isDrawerOpen ?? false) { + scaffold!.closeDrawer(); + return; + } + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + return; + } + SystemNavigator.pop(); + } + @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); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _handleBack(); }, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= AppShell.wideBreakpoint; + final pinned = + isWide && context.watch().navDrawerPinned; + + return pinned + ? _buildPinned(context) + : _buildTransient(context, isWide); + }, + ), ); } /// Null on detail screens, which show the panel but no bottom bar. Widget? _bottomBar() { - final index = selectedIndex; - final onSelected = onDestinationSelected; + final index = widget.selectedIndex; + final onSelected = widget.onDestinationSelected; if (index == null || onSelected == null) return null; return SafeArea( top: false, child: QuickSwitchBar( selectedIndex: index, onDestinationSelected: onSelected, - contactsUnreadCount: contactsUnreadCount, - channelsUnreadCount: channelsUnreadCount, + contactsUnreadCount: widget.contactsUnreadCount, + channelsUnreadCount: widget.channelsUnreadCount, ), ); } PreferredSizeWidget? _appBar(BuildContext context, bool pinned) { - return appBarBuilder?.call(context, pinned) ?? appBar; + return widget.appBarBuilder?.call(context, pinned) ?? widget.appBar; } /// Wide + pinned: the panel is laid out beside the body, not overlaid. Widget _buildPinned(BuildContext context) { return Scaffold( + key: _scaffoldKey, appBar: _appBar(context, true), body: SafeArea( top: false, @@ -103,20 +142,20 @@ class AppShell extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( - width: _drawerWidth, + width: AppShell._drawerWidth, child: _NavPanel( isWide: true, - content: drawerContent, - onDisconnect: onDisconnect, - onSettings: onSettings, + content: widget.drawerContent, + onDisconnect: widget.onDisconnect, + onSettings: widget.onSettings, ), ), const VerticalDivider(width: 1), - Expanded(child: body), + Expanded(child: widget.body), ], ), ), - floatingActionButton: floatingActionButton, + floatingActionButton: widget.floatingActionButton, bottomNavigationBar: _bottomBar(), ); } @@ -125,18 +164,19 @@ class AppShell extends StatelessWidget { /// the hamburger into the app bar automatically. Widget _buildTransient(BuildContext context, bool isWide) { return Scaffold( + key: _scaffoldKey, appBar: _appBar(context, false), drawer: Drawer( - width: _drawerWidth, + width: AppShell._drawerWidth, child: _NavPanel( isWide: isWide, - content: drawerContent, - onDisconnect: onDisconnect, - onSettings: onSettings, + content: widget.drawerContent, + onDisconnect: widget.onDisconnect, + onSettings: widget.onSettings, ), ), - body: body, - floatingActionButton: floatingActionButton, + body: widget.body, + floatingActionButton: widget.floatingActionButton, bottomNavigationBar: _bottomBar(), ); } diff --git a/lib/widgets/map_layer_panel.dart b/lib/widgets/map_layer_panel.dart index 13df116..29c18ea 100644 --- a/lib/widgets/map_layer_panel.dart +++ b/lib/widgets/map_layer_panel.dart @@ -46,6 +46,13 @@ class MapLayerPanel extends StatelessWidget { onChanged: service.setMapShowOtherNodes, ), const Divider(height: 1), + _toggle( + label: l10n.map_alwaysShowNames, + icon: Icons.label_outline, + value: settings.mapAlwaysShowNames, + onChanged: service.setMapAlwaysShowNames, + ), + const Divider(height: 1), _sectionHeader(theme, l10n.map_filterNodes), _toggle( label: l10n.map_showDiscoveryContacts, From 4dbceb3366c48f35c7d392f4e7ce1206226e9fb6 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 00:48:16 -0400 Subject: [PATCH 4/6] fix(#291): back must not drop a connected user on the radio list Two bugs, reported together from an S25 FE test. **Back popped to the scanner.** The previous commit made back pop whenever the route could be popped. The primary views sit on top of the scanner, so from the channel list back landed on the radio-connect screen. That is a regression I introduced: the `PopScope(canPop: !isConnected)` I removed existed to prevent exactly this, and I removed it without establishing why it was there. Back now distinguishes the two cases. A primary view (one carrying the bottom bar) hands back to the OS and backgrounds the app. A detail screen (a channel chat, which has no selectedIndex and is genuinely pushed) pops to its list. Reaching the scanner is what Disconnect is for, not what Back is for. **The scanner was a dead end while connected.** It only routed into the app on a connection transition, behind a one-shot flag that never reset while connected. So once showing with a live connection it stayed there, and pressing Connect did nothing, since there was nothing left to connect. It now routes in whenever it is the visible route and a connection is live, guarded against double-push by a dedicated flag rather than reusing _changedNavigation, which separately gates the disconnect-on-dispose cleanup. Known limitation, pre-existing and unchanged: this auto-entry is bluetooth only. USB and TCP connect from their own screens, which navigate themselves. flutter analyze clean, dart format clean, 469 tests pass. --- lib/screens/scanner_screen.dart | 22 ++++++++++++++++++---- lib/widgets/app_shell.dart | 20 +++++++++++++------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 3222168..f8eece9 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -25,6 +25,11 @@ class ScannerScreen extends StatefulWidget { class _ScannerScreenState extends State { bool _changedNavigation = false; + + /// Re-entrancy guard for routing into the app. Distinct from + /// [_changedNavigation], which records that we entered at least once and + /// gates the disconnect-on-dispose cleanup. + bool _routingIntoApp = false; late final MeshCoreConnector _connector; late final VoidCallback _connectionListener; BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown; @@ -42,12 +47,21 @@ class _ScannerScreenState extends State { } else if (_connector.state == MeshCoreConnectionState.connected && _connector.activeTransport == MeshCoreTransportType.bluetooth && isCurrentRoute && - !_changedNavigation) { + !_routingIntoApp) { + // Route in whenever this screen is showing while connected, not just + // on the first connect. Otherwise landing back here with a live + // connection is a dead end: Connect does nothing, because there is + // nothing left to connect. _changedNavigation = true; + _routingIntoApp = true; if (mounted) { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ChannelsScreen()), - ); + Navigator.of(context) + .push( + MaterialPageRoute(builder: (context) => const ChannelsScreen()), + ) + .then((_) { + if (mounted) _routingIntoApp = false; + }); } } }; diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 689caa3..b95ba8d 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -68,21 +68,27 @@ class _AppShellState extends State { /// System back, in priority order: /// 1. an open drawer closes, - /// 2. otherwise pop the route (a pushed chat returns to its list), - /// 3. otherwise hand back to the OS, so Android returns to the home - /// screen or the previous app rather than sitting on a dead press. + /// 2. on a detail screen, pop back to the list it came from, + /// 3. on a primary view, hand back to the OS so Android returns to the + /// home screen or the previous app. /// - /// Screens previously did this with `PopScope(canPop: !isConnected)`, which - /// swallowed back entirely while connected: the drawer would not close and - /// the app would never background. + /// Step 3 must NOT pop, even though the route below can be popped. The + /// primary views sit on top of the scanner, so popping would dump a + /// connected user back onto the radio-connect list. Reaching the scanner is + /// what Disconnect is for, not what Back is for. + /// + /// A primary view is one carrying the bottom bar; a detail screen (a channel + /// chat) has no [selectedIndex] and is genuinely pushed. void _handleBack() { final scaffold = _scaffoldKey.currentState; if (scaffold?.isDrawerOpen ?? false) { scaffold!.closeDrawer(); return; } + + final isPrimaryView = widget.selectedIndex != null; final navigator = Navigator.of(context); - if (navigator.canPop()) { + if (!isPrimaryView && navigator.canPop()) { navigator.pop(); return; } From c8b36b056d510a1cad5dbc9160cb05be6877cb05 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 00:56:52 -0400 Subject: [PATCH 5/6] fix(#291): route into the app from the scanner on any transport The scanner's auto-entry was gated on activeTransport == bluetooth, so the dead end fixed in the previous commit still applied to USB and TCP: a user landing on the scanner with a live serial or network connection stayed stuck, and pressing Connect did nothing, since there was nothing left to connect. Both are real deployment paths, so the gate is removed and the check is now transport-agnostic. The first-connect path for USB and TCP is unaffected. Those screens are pushed on top of the scanner and navigate themselves with pushReplacement; while they are connecting they are the current route, so the scanner's isCurrentRoute guard is false and it does not also push. That guard, not the transport check, is what prevents a double navigation. flutter analyze clean, dart format clean, 469 tests pass. --- lib/screens/scanner_screen.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index f8eece9..e2f635a 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -45,13 +45,18 @@ class _ScannerScreenState extends State { if (_connector.state == MeshCoreConnectionState.disconnected) { _changedNavigation = false; } else if (_connector.state == MeshCoreConnectionState.connected && - _connector.activeTransport == MeshCoreTransportType.bluetooth && isCurrentRoute && !_routingIntoApp) { // Route in whenever this screen is showing while connected, not just // on the first connect. Otherwise landing back here with a live // connection is a dead end: Connect does nothing, because there is // nothing left to connect. + // + // Deliberately transport-agnostic. This was bluetooth-only, which left + // the same dead end for USB and TCP users, who cannot connect their way + // out of it either. The first-connect path for those transports is not + // affected: their own screen sits on top while connecting, so + // isCurrentRoute is false here and they still navigate themselves. _changedNavigation = true; _routingIntoApp = true; if (mounted) { From c7fc4dca7b5769adcf99bde83173cf276bf79650 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 02:22:34 -0400 Subject: [PATCH 6/6] fix(#291): background the app on back instead of killing it Back at the root was calling SystemNavigator.pop(). That is not "background the app": on Android it calls finish() on the activity, which tears down the Flutter engine and drops the radio connection. Backing out and reopening therefore landed on a disconnected radio needing a fresh connect. Wrong call for the job, and worse than the behaviour it replaced. Backgrounding without finishing needs moveTaskToBack, which has no Flutter equivalent, so it goes over a method channel: - MainActivity exposes meshcore_open/app_lifecycle with a moveTaskToBack method, alongside the existing USB channel. - AppBackgrounder wraps it and is a no-op off Android, where programmatic backgrounding either does not apply (desktop windows close by their own chrome) or is forbidden (iOS). On those platforms back at the root stays unhandled rather than doing something destructive. - AppShell awaits it instead of calling SystemNavigator.pop(). The activity stays alive, so the connection survives and reopening returns to where the user was. flutter analyze clean, dart format clean, 469 tests pass. --- .../meshcore/meshcore_open/MainActivity.kt | 19 +++++++++++ lib/utils/app_backgrounder.dart | 33 +++++++++++++++++++ lib/widgets/app_shell.dart | 14 +++++--- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 lib/utils/app_backgrounder.dart diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt index 9022c8b..27136ea 100644 --- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt +++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt @@ -2,13 +2,32 @@ package com.meshcore.meshcore_open import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val usbFunctions by lazy { MeshcoreUsbFunctions(this) } + private val appLifecycleChannelName = "meshcore_open/app_lifecycle" + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) usbFunctions.configureFlutterEngine(flutterEngine) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + appLifecycleChannelName + ).setMethodCallHandler { call, result -> + when (call.method) { + // Send the task to the background WITHOUT finishing the + // activity. SystemNavigator.pop() calls finish(), which tears + // down the Flutter engine and drops the radio connection, so + // reopening the app lands on a disconnected radio. + "moveTaskToBack" -> { + result.success(moveTaskToBack(true)) + } + else -> result.notImplemented() + } + } } override fun onDestroy() { diff --git a/lib/utils/app_backgrounder.dart b/lib/utils/app_backgrounder.dart new file mode 100644 index 0000000..2a01908 --- /dev/null +++ b/lib/utils/app_backgrounder.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; + +import 'platform_info.dart'; + +/// Sends the app to the background without tearing it down. +/// +/// `SystemNavigator.pop()` is NOT this: on Android it calls `finish()` on the +/// activity, which destroys the Flutter engine and drops the radio connection, +/// so reopening the app finds itself disconnected. `moveTaskToBack` leaves the +/// activity alive and simply hands focus back to whatever was in front before. +class AppBackgrounder { + static const MethodChannel _channel = MethodChannel( + 'meshcore_open/app_lifecycle', + ); + + /// Returns true when the app was actually backgrounded. + /// + /// Android only. Desktop windows are closed by their own chrome and iOS + /// forbids programmatic backgrounding, so elsewhere this is a no-op and the + /// caller should leave the press unhandled rather than act on it. + static Future moveToBackground() async { + if (!PlatformInfo.isAndroid) return false; + try { + return await _channel.invokeMethod('moveTaskToBack') ?? false; + } on PlatformException catch (_) { + // Surface nothing to the user: the fallback is simply that back does + // nothing at the root, which is the pre-existing behaviour. + return false; + } on MissingPluginException catch (_) { + return false; + } + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index b95ba8d..d07c3ec 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../services/ui_view_state_service.dart'; +import '../utils/app_backgrounder.dart'; import 'quick_switch_bar.dart'; /// Shared shell for the primary views (Contacts / Channels / Map). @@ -69,8 +69,8 @@ class _AppShellState extends State { /// System back, in priority order: /// 1. an open drawer closes, /// 2. on a detail screen, pop back to the list it came from, - /// 3. on a primary view, hand back to the OS so Android returns to the - /// home screen or the previous app. + /// 3. on a primary view, send the app to the background so Android + /// returns to the home screen or the previous app. /// /// Step 3 must NOT pop, even though the route below can be popped. The /// primary views sit on top of the scanner, so popping would dump a @@ -79,7 +79,7 @@ class _AppShellState extends State { /// /// A primary view is one carrying the bottom bar; a detail screen (a channel /// chat) has no [selectedIndex] and is genuinely pushed. - void _handleBack() { + Future _handleBack() async { final scaffold = _scaffoldKey.currentState; if (scaffold?.isDrawerOpen ?? false) { scaffold!.closeDrawer(); @@ -92,7 +92,11 @@ class _AppShellState extends State { navigator.pop(); return; } - SystemNavigator.pop(); + + // Background, do NOT finish. SystemNavigator.pop() would call finish() on + // the activity, tearing down the Flutter engine and dropping the radio + // connection, so reopening the app would show a disconnected radio. + await AppBackgrounder.moveToBackground(); } @override