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.
integration/290-306-335
Strycher 2 days ago
parent 4ece02ceff
commit 9aaebce77c

@ -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",

@ -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:

@ -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 => 'Префикс на ключа';

@ -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';

@ -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';

@ -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';

@ -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é';

@ -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';

@ -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';

@ -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 => '主要なプレフィックス';

@ -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 => '핵심 접두사';

@ -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';

@ -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';

@ -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';

@ -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 => 'Префикс ключа';

@ -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';

@ -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';

@ -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';

@ -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 => 'Префікс ключа';

@ -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 => '关键字前缀';

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

@ -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<ChannelChatScreen> {
);
}
Future<void> _disconnect(BuildContext context) async {
final connector = context.read<MeshCoreConnector>();
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<ChannelChatScreen> {
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

@ -97,234 +97,229 @@ class _ChannelsScreenState extends State<ChannelsScreen>
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<MeshCoreConnector>().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<MeshCoreConnector>().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<UiViewStateService>()
.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<UiViewStateService>()
.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<UiViewStateService>()
.setChannelsSearchText(value);
},
);
},
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() {
if (!mounted) return;
context
.read<UiViewStateService>()
.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<Channel>.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<Channel>.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),
),
);
}

@ -317,107 +317,103 @@ class _ContactsScreenState extends State<ContactsScreen>
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),
);
}

@ -399,7 +399,8 @@ class _MapScreenState extends State<MapScreen> {
// 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<MapScreen> {
}
}
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 = <LineOfSightEndpoint>[];
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 = <LineOfSightEndpoint>[];
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),
),
);
},

@ -76,6 +76,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(mapShowOverlaps: value));
}
Future<void> setMapAlwaysShowNames(bool value) async {
await updateSettings(_settings.copyWith(mapAlwaysShowNames: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}

@ -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<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
/// 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<UiViewStateService>().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<UiViewStateService>().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(),
);
}

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

Loading…
Cancel
Save

Powered by TurnKey Linux.