Merge branch 'feat/290-panel-footer' into integration/290-306-335

integration/290-306-335
Strycher 23 hours ago
commit 47904eb4c3

@ -2,13 +2,32 @@ package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) } private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
private val appLifecycleChannelName = "meshcore_open/app_lifecycle"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
usbFunctions.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() { override fun onDestroy() {

@ -976,6 +976,7 @@
"map_repeaters": "Repeaters", "map_repeaters": "Repeaters",
"map_otherNodes": "Other Nodes", "map_otherNodes": "Other Nodes",
"map_showOverlaps": "Repeater Key Overlaps", "map_showOverlaps": "Repeater Key Overlaps",
"map_alwaysShowNames": "Always show names",
"map_keyPrefix": "Key Prefix", "map_keyPrefix": "Key Prefix",
"map_filterByKeyPrefix": "Filter by key prefix", "map_filterByKeyPrefix": "Filter by key prefix",
"map_publicKeyPrefix": "Public key prefix", "map_publicKeyPrefix": "Public key prefix",

@ -3412,6 +3412,12 @@ abstract class AppLocalizations {
/// **'Repeater Key Overlaps'** /// **'Repeater Key Overlaps'**
String get map_showOverlaps; 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. /// No description provided for @map_keyPrefix.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

@ -1890,6 +1890,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Покриване на ключа на повтаряча'; String get map_showOverlaps => 'Покриване на ключа на повтаряча';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Префикс на ключа'; String get map_keyPrefix => 'Префикс на ключа';

@ -1887,6 +1887,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Überlappungen der Repeater-Taste'; String get map_showOverlaps => 'Überlappungen der Repeater-Taste';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Schlüsselpräfix'; String get map_keyPrefix => 'Schlüsselpräfix';

@ -1854,6 +1854,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Repeater Key Overlaps'; String get map_showOverlaps => 'Repeater Key Overlaps';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Key Prefix'; String get map_keyPrefix => 'Key Prefix';

@ -1885,6 +1885,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Superposiciones de tecla repetidora'; String get map_showOverlaps => 'Superposiciones de tecla repetidora';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefijo de clave'; String get map_keyPrefix => 'Prefijo de clave';

@ -1895,6 +1895,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Chevauchement de la touche répétitive'; String get map_showOverlaps => 'Chevauchement de la touche répétitive';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Préfixe clé'; String get map_keyPrefix => 'Préfixe clé';

@ -1896,6 +1896,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek'; String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Kulcsfontosságú előtag'; String get map_keyPrefix => 'Kulcsfontosságú előtag';

@ -1888,6 +1888,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore'; String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefisso Chiave'; String get map_keyPrefix => 'Prefisso Chiave';

@ -1811,6 +1811,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get map_showOverlaps => 'リピーターキーの重複'; String get map_showOverlaps => 'リピーターキーの重複';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => '主要なプレフィックス'; String get map_keyPrefix => '主要なプレフィックス';

@ -1807,6 +1807,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get map_showOverlaps => '반복 키 중복'; String get map_showOverlaps => '반복 키 중복';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => '핵심 접두사'; String get map_keyPrefix => '핵심 접두사';

@ -1874,6 +1874,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Herhalingssleutel overlapt'; String get map_showOverlaps => 'Herhalingssleutel overlapt';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefix sleutel'; String get map_keyPrefix => 'Prefix sleutel';

@ -1900,6 +1900,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Nakładające się klucze przekaźników'; String get map_showOverlaps => 'Nakładające się klucze przekaźników';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefiks klucza'; String get map_keyPrefix => 'Prefiks klucza';

@ -1885,6 +1885,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Sobreposições da Chave Repeater'; String get map_showOverlaps => 'Sobreposições da Chave Repeater';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefixo Chave'; String get map_keyPrefix => 'Prefixo Chave';

@ -1889,6 +1889,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Перекрытия ключа повтора'; String get map_showOverlaps => 'Перекрытия ключа повтора';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Префикс ключа'; String get map_keyPrefix => 'Префикс ключа';

@ -1876,6 +1876,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Prekrývanie opakovača kľúča'; String get map_showOverlaps => 'Prekrývanie opakovača kľúča';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Päťciferné predpona'; String get map_keyPrefix => 'Päťciferné predpona';

@ -1871,6 +1871,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja'; String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Predpona ključa'; String get map_keyPrefix => 'Predpona ključa';

@ -1864,6 +1864,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Repeater-nyckelöverlappningar'; String get map_showOverlaps => 'Repeater-nyckelöverlappningar';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Nyckelprefix'; String get map_keyPrefix => 'Nyckelprefix';

@ -1884,6 +1884,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Перекриття ключів ретрансляторів'; String get map_showOverlaps => 'Перекриття ключів ретрансляторів';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Префікс ключа'; String get map_keyPrefix => 'Префікс ключа';

@ -1778,6 +1778,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_showOverlaps => '重复键重叠'; String get map_showOverlaps => '重复键重叠';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => '关键字前缀'; String get map_keyPrefix => '关键字前缀';

@ -112,6 +112,9 @@ class AppSettings {
final bool mapShowChatNodes; final bool mapShowChatNodes;
final bool mapShowOtherNodes; final bool mapShowOtherNodes;
final bool mapShowOverlaps; 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 double mapTimeFilterHours; // 0 = all time
final bool mapKeyPrefixEnabled; final bool mapKeyPrefixEnabled;
final String mapKeyPrefix; final String mapKeyPrefix;
@ -188,6 +191,7 @@ class AppSettings {
this.mapShowChatNodes = true, this.mapShowChatNodes = true,
this.mapShowOtherNodes = true, this.mapShowOtherNodes = true,
this.mapShowOverlaps = false, this.mapShowOverlaps = false,
this.mapAlwaysShowNames = false,
this.mapTimeFilterHours = 0, // Default to all time this.mapTimeFilterHours = 0, // Default to all time
this.mapKeyPrefixEnabled = false, this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '', this.mapKeyPrefix = '',
@ -252,6 +256,7 @@ class AppSettings {
'map_show_chat_nodes': mapShowChatNodes, 'map_show_chat_nodes': mapShowChatNodes,
'map_show_other_nodes': mapShowOtherNodes, 'map_show_other_nodes': mapShowOtherNodes,
'map_show_overlaps': mapShowOverlaps, 'map_show_overlaps': mapShowOverlaps,
'map_always_show_names': mapAlwaysShowNames,
'map_time_filter_hours': mapTimeFilterHours, 'map_time_filter_hours': mapTimeFilterHours,
'map_key_prefix_enabled': mapKeyPrefixEnabled, 'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix, 'map_key_prefix': mapKeyPrefix,
@ -338,6 +343,7 @@ class AppSettings {
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true, mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true, mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false, mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false,
mapAlwaysShowNames: json['map_always_show_names'] as bool? ?? false,
mapTimeFilterHours: mapTimeFilterHours:
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0, (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false, mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
@ -462,6 +468,7 @@ class AppSettings {
bool? mapShowChatNodes, bool? mapShowChatNodes,
bool? mapShowOtherNodes, bool? mapShowOtherNodes,
bool? mapShowOverlaps, bool? mapShowOverlaps,
bool? mapAlwaysShowNames,
double? mapTimeFilterHours, double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled, bool? mapKeyPrefixEnabled,
String? mapKeyPrefix, String? mapKeyPrefix,
@ -510,6 +517,7 @@ class AppSettings {
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes, mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes, mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps, mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps,
mapAlwaysShowNames: mapAlwaysShowNames ?? this.mapAlwaysShowNames,
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours, mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled, mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix, mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,

@ -29,6 +29,8 @@ import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import 'settings_screen.dart';
import '../utils/dialog_utils.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../widgets/channel_drawer_list.dart'; import '../widgets/channel_drawer_list.dart';
import '../widgets/mention_autocomplete.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. /// Bottom-bar navigation out of an open channel.
/// ///
/// Tapping Channels returns to the channel list. Tapping Contacts or Map /// Tapping Channels returns to the channel list. Tapping Contacts or Map
@ -351,6 +358,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
currentChannelIndex: _currentChannel.index, currentChannelIndex: _currentChannel.index,
onChannelSelected: _switchChannel, 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( appBarBuilder: (context, pinned) => AppBar(
// Pinned: the panel is already docked, so no hamburger is needed. // Pinned: the panel is already docked, so no hamburger is needed.
// Unpinned: this is a pushed route, so Scaffold would put a back arrow // Unpinned: this is a pushed route, so Scaffold would put a back arrow

@ -97,251 +97,229 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final allowBack = !connector.isConnected; return AppShell(
selectedIndex: 1,
return PopScope( onDestinationSelected: (index) => _handleQuickSwitch(index, context),
canPop: allowBack, contactsUnreadCount: connector.getTotalContactsUnreadCount(),
child: AppShell( channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
selectedIndex: 1, drawerContent: ChannelDrawerList(
onDestinationSelected: (index) => _handleQuickSwitch(index, context), onChannelSelected: (channel) => _openChannel(context, channel),
contactsUnreadCount: connector.getTotalContactsUnreadCount(), ),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(), onDisconnect: () => _disconnect(context),
drawerContent: ChannelDrawerList( onSettings: () => Navigator.push(
onChannelSelected: (channel) => _openChannel(context, channel), context,
), MaterialPageRoute(builder: (context) => const SettingsScreen()),
appBar: AppBar( ),
title: AppBarTitle(context.l10n.channels_title), appBar: AppBar(
centerTitle: true, title: AppBarTitle(context.l10n.channels_title),
bottom: const SyncProgressAppBarBottom(), centerTitle: true,
actions: [ 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( PopupMenuButton(
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
const Icon(Icons.logout, color: Colors.red), const Icon(Icons.groups),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.common_disconnect), Text(context.l10n.community_manageCommunities),
], ],
), ),
onTap: () => _disconnect(context), onTap: () => _showManageCommunitiesDialog(context),
),
if (_communities.isNotEmpty)
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.groups),
const SizedBox(width: 8),
Text(context.l10n.community_manageCommunities),
],
),
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( body: RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await context.read<MeshCoreConnector>().getChannels(force: true); await context.read<MeshCoreConnector>().getChannels(force: true);
}, },
child: () { child: () {
final channels = connector.channels; final channels = connector.channels;
final waitingForFirstChannel = final waitingForFirstChannel =
connector.isLoadingChannels && channels.isEmpty; connector.isLoadingChannels && channels.isEmpty;
// Only block the list while the first channel is actively loading. // Only block the list while the first channel is actively loading.
// If the initial sync aborts, show cached/partial channels instead // If the initial sync aborts, show cached/partial channels instead
// of trapping the user behind an idle spinner. // of trapping the user behind an idle spinner.
if (waitingForFirstChannel) { if (waitingForFirstChannel) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (channels.isEmpty) { if (channels.isEmpty) {
return ListView( return ListView(
children: [ children: [
SizedBox( SizedBox(
height: MediaQuery.of(context).size.height - 200, height: MediaQuery.of(context).size.height - 200,
child: EmptyState( child: EmptyState(
icon: Icons.tag, icon: Icons.tag,
title: context.l10n.channels_noChannelsConfigured, title: context.l10n.channels_noChannelsConfigured,
action: FilledButton.icon( action: FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector), onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public), icon: const Icon(Icons.public),
label: Text(context.l10n.channels_addPublicChannel), label: Text(context.l10n.channels_addPublicChannel),
),
), ),
), ),
], ),
); ],
}
final filteredChannels = _filterAndSortChannels(
channels,
connector,
viewState,
); );
}
return Column( final filteredChannels = _filterAndSortChannels(
children: [ channels,
Padding( connector,
padding: const EdgeInsets.all(8.0), viewState,
child: TextField( );
controller: _searchController,
decoration: InputDecoration( return Column(
hintText: context.l10n.channels_searchChannels, children: [
prefixIcon: const Icon(Icons.search), Padding(
suffixIcon: Row( padding: const EdgeInsets.all(8.0),
mainAxisSize: MainAxisSize.min, child: TextField(
children: [ controller: _searchController,
if (viewState.channelsSearchText.isNotEmpty) decoration: InputDecoration(
IconButton( hintText: context.l10n.channels_searchChannels,
icon: const Icon(Icons.clear), prefixIcon: const Icon(Icons.search),
onPressed: () { suffixIcon: Row(
_searchDebounce?.cancel(); mainAxisSize: MainAxisSize.min,
_searchDebounce = null; children: [
_searchController.clear(); if (viewState.channelsSearchText.isNotEmpty)
context IconButton(
.read<UiViewStateService>() icon: const Icon(Icons.clear),
.setChannelsSearchText(''); onPressed: () {
}, _searchDebounce?.cancel();
), _searchDebounce = null;
_buildFilterButton(viewState), _searchController.clear();
], context
), .read<UiViewStateService>()
border: OutlineInputBorder( .setChannelsSearchText('');
borderRadius: BorderRadius.circular(12), },
), ),
contentPadding: const EdgeInsets.symmetric( _buildFilterButton(viewState),
horizontal: 16, ],
vertical: 12, ),
), 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 Expanded(
? ListView( child: filteredChannels.isEmpty
children: [ ? ListView(
SizedBox( children: [
height: MediaQuery.of(context).size.height - 300, SizedBox(
child: Center( height: MediaQuery.of(context).size.height - 300,
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
Icon( children: [
Icons.search_off, Icon(
size: 64, Icons.search_off,
color: Colors.grey[400], size: 64,
), color: Colors.grey[400],
const SizedBox(height: 16), ),
Text( const SizedBox(height: 16),
context.l10n.channels_noChannelsFound, Text(
style: TextStyle( context.l10n.channels_noChannelsFound,
fontSize: 16, style: TextStyle(
color: Colors.grey[600], 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) { : (viewState.channelsSortOption ==
// onReorderItem already adjusts newIndex after the ChannelSortOption.manual &&
// removed item, unlike the deprecated onReorder. viewState.channelsSearchText.isEmpty)
final reordered = List<Channel>.from( ? ReorderableListView.builder(
filteredChannels, padding: const EdgeInsets.only(
); left: 16,
final item = reordered.removeAt(oldIndex); right: 16,
reordered.insert(newIndex, item); top: 8,
unawaited( bottom: 88,
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,
);
},
), ),
), buildDefaultDragHandles: false,
], itemCount: filteredChannels.length,
); onReorderItem: (oldIndex, newIndex) {
}(), // onReorderItem already adjusts newIndex after the
), // removed item, unlike the deprecated onReorder.
floatingActionButton: FloatingActionButton( final reordered = List<Channel>.from(
onPressed: () => _showAddChannelDialog(context), filteredChannels,
tooltip: context.l10n.channels_addChannel, );
child: const Icon(Icons.add), 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,125 +317,103 @@ class _ContactsScreenState extends State<ContactsScreen>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final allowBack = !connector.isConnected; return AppShell(
return PopScope( selectedIndex: 0,
canPop: allowBack, onDestinationSelected: (index) => _handleQuickSwitch(index, context),
child: AppShell( contactsUnreadCount: connector.getTotalContactsUnreadCount(),
selectedIndex: 0, channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
onDestinationSelected: (index) => _handleQuickSwitch(index, context), drawerContent: const ContactFilterRail(),
contactsUnreadCount: connector.getTotalContactsUnreadCount(), onDisconnect: () => _disconnect(context, connector),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(), onSettings: () => Navigator.push(
drawerContent: const ContactFilterRail(), context,
appBar: AppBar( MaterialPageRoute(builder: (context) => const SettingsScreen()),
title: AppBarTitle(context.l10n.contacts_title), ),
bottom: const SyncProgressAppBarBottom(), appBar: AppBar(
actions: [ title: AppBarTitle(context.l10n.contacts_title),
PopupMenuButton( bottom: const SyncProgressAppBarBottom(),
itemBuilder: (context) => [ actions: [
PopupMenuItem( PopupMenuButton(
child: Row( itemBuilder: (context) => [
children: [ PopupMenuItem(
const Icon(Icons.connect_without_contact), child: Row(
const SizedBox(width: 8), children: [
Text(context.l10n.contacts_zeroHopAdvert), 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),
),
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
),
onTap: () => {
connector.sendSelfAdvert(flood: true),
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_advertisementSent),
),
},
), ),
PopupMenuItem( onTap: () => {
child: Row( connector.sendSelfAdvert(flood: false),
children: [ showDismissibleSnackBar(
const Icon(Icons.copy), context,
const SizedBox(width: 8), content: Text(context.l10n.settings_advertisementSent),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
), ),
onTap: () => _contactExport(Uint8List.fromList([])), },
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
), ),
PopupMenuItem( onTap: () => {
child: Row( connector.sendSelfAdvert(flood: true),
children: [ showDismissibleSnackBar(
const Icon(Icons.paste), context,
const SizedBox(width: 8), content: Text(context.l10n.settings_advertisementSent),
Text(context.l10n.contacts_addContactFromClipboard),
],
), ),
onTap: () => _contactImport(), },
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.copy),
const SizedBox(width: 8),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
), ),
], onTap: () => _contactExport(Uint8List.fromList([])),
icon: const Icon(Icons.connect_without_contact), ),
), PopupMenuItem(
PopupMenuButton( child: Row(
itemBuilder: (context) => [ children: [
PopupMenuItem( const Icon(Icons.paste),
child: Row( const SizedBox(width: 8),
children: [ Text(context.l10n.contacts_addContactFromClipboard),
const Icon(Icons.logout, color: Colors.red), ],
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
), ),
PopupMenuItem( onTap: () => _contactImport(),
child: Row( ),
children: [ ],
const Icon(Icons.person_add_rounded), icon: const Icon(Icons.connect_without_contact),
const SizedBox(width: 8), ),
Text(context.l10n.discoveredContacts_Title), // Disconnect and Settings moved to the panel footer (#290).
], // Discovered contacts is screen-level and stays here.
), PopupMenuButton(
onTap: () => Navigator.push( itemBuilder: (context) => [
context, PopupMenuItem(
MaterialPageRoute( child: Row(
builder: (context) => const DiscoveryScreen(), children: [
), const Icon(Icons.person_add_rounded),
), const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
), ),
PopupMenuItem( onTap: () => Navigator.push(
child: Row( context,
children: [ MaterialPageRoute(
const Icon(Icons.settings), builder: (context) => const DiscoveryScreen(),
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: _buildContactsBody(context, connector),
), ),
body: _buildContactsBody(context, connector),
); );
} }

@ -24,6 +24,7 @@ import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../widgets/map_layer_panel.dart';
import '../widgets/sync_progress_overlay.dart'; import '../widgets/sync_progress_overlay.dart';
import '../icons/los_icon.dart'; import '../icons/los_icon.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
@ -398,7 +399,8 @@ class _MapScreenState extends State<MapScreen> {
// Re center map after removed markers have loaded // Re center map after removed markers have loaded
if (!_hasInitializedMap && _removedMarkersLoaded) { if (!_hasInitializedMap && _removedMarkersLoaded) {
_hasInitializedMap = true; _hasInitializedMap = true;
_showNodeLabels = initialZoom >= _labelZoomThreshold; _showNodeLabels =
settings.mapAlwaysShowNames || initialZoom >= _labelZoomThreshold;
if (hasMapContent) { if (hasMapContent) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
@ -408,281 +410,253 @@ class _MapScreenState extends State<MapScreen> {
} }
} }
final allowBack = !connector.isConnected; return AppShell(
selectedIndex: 2,
return PopScope( onDestinationSelected: (index) => _handleQuickSwitch(index, context),
canPop: allowBack, contactsUnreadCount: connector.getTotalContactsUnreadCount(),
child: AppShell( channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
selectedIndex: 2, drawerContent: const MapLayerPanel(),
onDestinationSelected: (index) => onDisconnect: () => _disconnect(context, connector),
_handleQuickSwitch(index, context), onSettings: () => Navigator.push(
contactsUnreadCount: connector.getTotalContactsUnreadCount(), context,
channelsUnreadCount: connector.getTotalChannelsUnreadCount(), MaterialPageRoute(builder: (context) => const SettingsScreen()),
appBar: AppBar( ),
title: AppBarTitle(context.l10n.map_title), appBar: AppBar(
centerTitle: true, title: AppBarTitle(context.l10n.map_title),
bottom: const SyncProgressAppBarBottom(), centerTitle: true,
actions: [ bottom: const SyncProgressAppBarBottom(),
if (!_isBuildingPathTrace) actions: [
IconButton( if (!_isBuildingPathTrace)
icon: const Icon(Icons.radar), IconButton(
onPressed: () => _startPath( icon: const Icon(Icons.radar),
LatLng(connector.selfLatitude!, connector.selfLongitude!), onPressed: () => _startPath(
), LatLng(connector.selfLatitude!, connector.selfLongitude!),
tooltip: context.l10n.contacts_pathTrace,
), ),
if (!_isBuildingPathTrace) tooltip: context.l10n.contacts_pathTrace,
IconButton( ),
icon: const LosIcon(), if (!_isBuildingPathTrace)
onPressed: () { IconButton(
final candidates = <LineOfSightEndpoint>[]; icon: const LosIcon(),
if (connector.selfLatitude != null && onPressed: () {
connector.selfLongitude != null) { final candidates = <LineOfSightEndpoint>[];
candidates.add( if (connector.selfLatitude != null &&
LineOfSightEndpoint( connector.selfLongitude != null) {
label: context.l10n.pathTrace_you, candidates.add(
point: LatLng( LineOfSightEndpoint(
connector.selfLatitude!, label: context.l10n.pathTrace_you,
connector.selfLongitude!, point: LatLng(
), connector.selfLatitude!,
color: Colors.teal, connector.selfLongitude!,
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,
), ),
color: Colors.teal,
icon: Icons.person_pin_circle,
), ),
); );
}, }
tooltip: context.l10n.map_lineOfSight, for (final c in contactsWithLocation) {
), candidates.add(
PopupMenuButton( LineOfSightEndpoint(
itemBuilder: (context) => [ label: c.name,
PopupMenuItem( point: LatLng(c.latitude!, c.longitude!),
child: Row( color: _getNodeColor(c.type),
children: [ icon: _getNodeIcon(c.type),
const Icon(Icons.logout, color: Colors.red), ),
const SizedBox(width: 8), );
Text(context.l10n.common_disconnect), }
], Navigator.push(
), context,
onTap: () => _disconnect(context, connector), MaterialPageRoute(
), builder: (context) => LineOfSightMapScreen(
PopupMenuItem( title: context.l10n.map_losScreenTitle,
child: Row( candidates: candidates,
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), tooltip: context.l10n.map_lineOfSight,
), ),
], ],
), ),
body: Stack( body: Stack(
children: [ children: [
FlutterMap( FlutterMap(
mapController: _mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCenter: center, initialCenter: center,
initialZoom: initialZoom, initialZoom: initialZoom,
minZoom: _mapMinZoom, minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom, maxZoom: _mapMaxZoom,
interactionOptions: InteractionOptions( interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate, flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005, scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions: cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(), CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop keyboardOptions: isDesktop
? const KeyboardOptions( ? const KeyboardOptions(
enableArrowKeysPanning: true, enableArrowKeysPanning: true,
enableWASDPanning: true, enableWASDPanning: true,
enableRFZooming: true, enableRFZooming: true,
) )
: const KeyboardOptions.disabled(), : const KeyboardOptions.disabled(),
), ),
onTap: (_, latLng) { onTap: (_, latLng) {
if (_isSelectingPoi) { if (_isSelectingPoi) {
setState(() { setState(() {
_isSelectingPoi = false; _isSelectingPoi = false;
}); });
_shareMarker( _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(
context: context, context: context,
connector: connector, connector: connector,
position: latLng, position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
); );
}, }
onPositionChanged: (camera, hasGesture) { },
final shouldShow = camera.zoom >= _labelZoomThreshold; onLongPress: (_, latLng) {
if (shouldShow != _showNodeLabels && mounted) { if (_isSelectingPoi) {
setState(() { setState(() {
_showNodeLabels = shouldShow; _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: [ if (_polylines.isNotEmpty && _isBuildingPathTrace)
TileLayer( PolylineLayer(polylines: _polylines),
urlTemplate: kMapTileUrlTemplate, if (sharedMarkerPolylines.isNotEmpty)
tileProvider: tileCache.tileProvider, PolylineLayer(polylines: sharedMarkerPolylines),
userAgentPackageName: MarkerLayer(
MapTileCacheService.userAgentPackageName, markers: [
maxZoom: 19, if (highlightPosition != null)
), Marker(
if (_polylines.isNotEmpty && _isBuildingPathTrace) point: highlightPosition,
PolylineLayer(polylines: _polylines), width: 40,
if (sharedMarkerPolylines.isNotEmpty) height: 40,
PolylineLayer(polylines: sharedMarkerPolylines), child: IgnorePointer(
MarkerLayer( child: Icon(
markers: [ Icons.location_on_outlined,
if (highlightPosition != null) color: Colors.red[600],
Marker( size: 34,
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 (!settings.mapShowOverlaps)
if (connector.selfLatitude != null && ..._buildGuessedMarker(
connector.selfLongitude != null) guessedLocations,
Marker( showLabels:
point: LatLng( settings.mapAlwaysShowNames || _showNodeLabels,
connector.selfLatitude!, ),
connector.selfLongitude!, ..._buildMarkers(
), contactsWithLocation,
width: 40, settings,
height: 40, showLabels:
child: IgnorePointer( settings.mapAlwaysShowNames || _showNodeLabels,
ignoring: true, ),
child: Container( ...sharedMarkers.map(_buildSharedMarker),
padding: const EdgeInsets.all(4), if (connector.selfLatitude != null &&
decoration: BoxDecoration( connector.selfLongitude != null)
color: Colors.teal, Marker(
shape: BoxShape.circle, point: LatLng(
border: Border.all( connector.selfLatitude!,
color: Colors.white, connector.selfLongitude!,
width: 2, ),
), width: 40,
boxShadow: [ height: 40,
BoxShadow( child: IgnorePointer(
color: Colors.black.withValues( ignoring: true,
alpha: 0.3, child: Container(
), padding: const EdgeInsets.all(4),
blurRadius: 4, decoration: BoxDecoration(
offset: const Offset(0, 2), color: Colors.teal,
), shape: BoxShape.circle,
], border: Border.all(
),
alignment: Alignment.center,
child: const Icon(
Icons.person_pin_circle,
color: Colors.white, 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 && if (_showNodeLabels &&
connector.selfLongitude != null) connector.selfLatitude != null &&
_buildNodeLabelMarker( connector.selfLongitude != null)
point: LatLng( _buildNodeLabelMarker(
connector.selfLatitude!, point: LatLng(
connector.selfLongitude!, connector.selfLatitude!,
), connector.selfLongitude!,
label: context.l10n.pathTrace_you,
), ),
], label: context.l10n.pathTrace_you,
), ),
], ],
),
if (!_isBuildingPathTrace)
_buildLegend(
contacts,
contactsWithLocation,
settings,
sharedMarkers.length,
guessedLocations.length,
),
if (isDesktop)
_buildDesktopMapControls(
context,
center: center,
zoom: initialZoom,
hasPathSelector: _isBuildingPathTrace,
), ),
if (_isBuildingPathTrace) _buildPathTraceOverlay(), ],
], ),
), if (!_isBuildingPathTrace)
floatingActionButton: FloatingActionButton( _buildLegend(
onPressed: () => _showFilterDialog(context, settingsService), contacts,
tooltip: context.l10n.map_filterNodes, contactsWithLocation,
child: const Icon(Icons.filter_list), 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),
), ),
); );
}, },

@ -25,6 +25,11 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State<ScannerScreen> { class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false; 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 MeshCoreConnector _connector;
late final VoidCallback _connectionListener; late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown; BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
@ -40,14 +45,28 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (_connector.state == MeshCoreConnectionState.disconnected) { if (_connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false; _changedNavigation = false;
} else if (_connector.state == MeshCoreConnectionState.connected && } else if (_connector.state == MeshCoreConnectionState.connected &&
_connector.activeTransport == MeshCoreTransportType.bluetooth &&
isCurrentRoute && 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.
//
// 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; _changedNavigation = true;
_routingIntoApp = true;
if (mounted) { if (mounted) {
Navigator.of(context).push( Navigator.of(context)
MaterialPageRoute(builder: (context) => const ChannelsScreen()), .push(
); MaterialPageRoute(builder: (context) => const ChannelsScreen()),
)
.then((_) {
if (mounted) _routingIntoApp = false;
});
} }
} }
}; };

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

@ -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<bool> moveToBackground() async {
if (!PlatformInfo.isAndroid) return false;
try {
return await _channel.invokeMethod<bool>('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;
}
}
}

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/ui_view_state_service.dart'; import '../services/ui_view_state_service.dart';
import '../utils/app_backgrounder.dart';
import 'quick_switch_bar.dart'; import 'quick_switch_bar.dart';
/// Shared shell for the primary views (Contacts / Channels / Map). /// Shared shell for the primary views (Contacts / Channels / Map).
@ -11,7 +13,7 @@ import 'quick_switch_bar.dart';
/// dockable pane that can be pinned open on wide ones. /// 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. /// 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 wideBreakpoint = 720;
static const double _drawerWidth = 300; static const double _drawerWidth = 300;
@ -36,6 +38,12 @@ class AppShell extends StatelessWidget {
/// List content for the active view. Null renders an empty drawer body. /// List content for the active view. Null renders an empty drawer body.
final Widget? drawerContent; 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({ const AppShell({
super.key, super.key,
required this.body, required this.body,
@ -45,48 +53,98 @@ class AppShell extends StatelessWidget {
this.appBarBuilder, this.appBarBuilder,
this.floatingActionButton, this.floatingActionButton,
this.drawerContent, this.drawerContent,
this.onDisconnect,
this.onSettings,
this.contactsUnreadCount = 0, this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0, 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. on a detail screen, pop back to the list it came from,
/// 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
/// 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.
Future<void> _handleBack() async {
final scaffold = _scaffoldKey.currentState;
if (scaffold?.isDrawerOpen ?? false) {
scaffold!.closeDrawer();
return;
}
final isPrimaryView = widget.selectedIndex != null;
final navigator = Navigator.of(context);
if (!isPrimaryView && navigator.canPop()) {
navigator.pop();
return;
}
// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return PopScope(
builder: (context, constraints) { canPop: false,
final isWide = constraints.maxWidth >= wideBreakpoint; onPopInvokedWithResult: (didPop, _) {
final pinned = if (didPop) return;
isWide && context.watch<UiViewStateService>().navDrawerPinned; _handleBack();
return pinned
? _buildPinned(context)
: _buildTransient(context, isWide);
}, },
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. /// Null on detail screens, which show the panel but no bottom bar.
Widget? _bottomBar() { Widget? _bottomBar() {
final index = selectedIndex; final index = widget.selectedIndex;
final onSelected = onDestinationSelected; final onSelected = widget.onDestinationSelected;
if (index == null || onSelected == null) return null; if (index == null || onSelected == null) return null;
return SafeArea( return SafeArea(
top: false, top: false,
child: QuickSwitchBar( child: QuickSwitchBar(
selectedIndex: index, selectedIndex: index,
onDestinationSelected: onSelected, onDestinationSelected: onSelected,
contactsUnreadCount: contactsUnreadCount, contactsUnreadCount: widget.contactsUnreadCount,
channelsUnreadCount: channelsUnreadCount, channelsUnreadCount: widget.channelsUnreadCount,
), ),
); );
} }
PreferredSizeWidget? _appBar(BuildContext context, bool pinned) { 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. /// Wide + pinned: the panel is laid out beside the body, not overlaid.
Widget _buildPinned(BuildContext context) { Widget _buildPinned(BuildContext context) {
return Scaffold( return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, true), appBar: _appBar(context, true),
body: SafeArea( body: SafeArea(
top: false, top: false,
@ -94,15 +152,20 @@ class AppShell extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SizedBox( SizedBox(
width: _drawerWidth, width: AppShell._drawerWidth,
child: _NavPanel(isWide: true, content: drawerContent), child: _NavPanel(
isWide: true,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
), ),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded(child: body), Expanded(child: widget.body),
], ],
), ),
), ),
floatingActionButton: floatingActionButton, floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(), bottomNavigationBar: _bottomBar(),
); );
} }
@ -111,13 +174,19 @@ class AppShell extends StatelessWidget {
/// the hamburger into the app bar automatically. /// the hamburger into the app bar automatically.
Widget _buildTransient(BuildContext context, bool isWide) { Widget _buildTransient(BuildContext context, bool isWide) {
return Scaffold( return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, false), appBar: _appBar(context, false),
drawer: Drawer( drawer: Drawer(
width: _drawerWidth, width: AppShell._drawerWidth,
child: _NavPanel(isWide: isWide, content: drawerContent), child: _NavPanel(
isWide: isWide,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
), ),
body: body, body: widget.body,
floatingActionButton: floatingActionButton, floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(), bottomNavigationBar: _bottomBar(),
); );
} }
@ -128,8 +197,15 @@ class AppShell extends StatelessWidget {
class _NavPanel extends StatelessWidget { class _NavPanel extends StatelessWidget {
final bool isWide; final bool isWide;
final Widget? content; final Widget? content;
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const _NavPanel({required this.isWide, this.content}); const _NavPanel({
required this.isWide,
this.content,
this.onDisconnect,
this.onSettings,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -164,6 +240,51 @@ class _NavPanel extends StatelessWidget {
), ),
if (isWide) const Divider(height: 1), if (isWide) const Divider(height: 1),
Expanded(child: content ?? const SizedBox.shrink()), 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,
),
),
], ],
), ),
); );

@ -0,0 +1,106 @@
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<AppSettingsService>();
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),
_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,
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<bool> onChanged,
}) {
return SwitchListTile(
dense: true,
secondary: Icon(icon, size: 20),
title: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
value: value,
onChanged: onChanged,
);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.