diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt index 9022c8b..27136ea 100644 --- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt +++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt @@ -2,13 +2,32 @@ package com.meshcore.meshcore_open import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val usbFunctions by lazy { MeshcoreUsbFunctions(this) } + private val appLifecycleChannelName = "meshcore_open/app_lifecycle" + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) usbFunctions.configureFlutterEngine(flutterEngine) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + appLifecycleChannelName + ).setMethodCallHandler { call, result -> + when (call.method) { + // Send the task to the background WITHOUT finishing the + // activity. SystemNavigator.pop() calls finish(), which tears + // down the Flutter engine and drops the radio connection, so + // reopening the app lands on a disconnected radio. + "moveTaskToBack" -> { + result.success(moveTaskToBack(true)) + } + else -> result.notImplemented() + } + } } override fun onDestroy() { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 48c6f31..a7b3413 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -976,6 +976,7 @@ "map_repeaters": "Repeaters", "map_otherNodes": "Other Nodes", "map_showOverlaps": "Repeater Key Overlaps", + "map_alwaysShowNames": "Always show names", "map_keyPrefix": "Key Prefix", "map_filterByKeyPrefix": "Filter by key prefix", "map_publicKeyPrefix": "Public key prefix", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2c425e2..0ad7394 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3412,6 +3412,12 @@ abstract class AppLocalizations { /// **'Repeater Key Overlaps'** String get map_showOverlaps; + /// No description provided for @map_alwaysShowNames. + /// + /// In en, this message translates to: + /// **'Always show names'** + String get map_alwaysShowNames; + /// No description provided for @map_keyPrefix. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index ee62916..a379cc6 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1890,6 +1890,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_showOverlaps => 'Покриване на ключа на повтаряча'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Префикс на ключа'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 92c1102..b60bea3 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1887,6 +1887,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get map_showOverlaps => 'Überlappungen der Repeater-Taste'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Schlüsselpräfix'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7989c98..db7573f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1854,6 +1854,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_showOverlaps => 'Repeater Key Overlaps'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Key Prefix'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index df55c77..9253a00 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1885,6 +1885,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get map_showOverlaps => 'Superposiciones de tecla repetidora'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefijo de clave'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e02d168..19bd894 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1895,6 +1895,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get map_showOverlaps => 'Chevauchement de la touche répétitive'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Préfixe clé'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 3dabd04..98933ef 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1896,6 +1896,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Kulcsfontosságú előtag'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index c4ad345..b0197a8 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1888,6 +1888,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefisso Chiave'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 1c2edbf..b244d39 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1811,6 +1811,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get map_showOverlaps => 'リピーターキーの重複'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => '主要なプレフィックス'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6403027..90228a2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1807,6 +1807,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get map_showOverlaps => '반복 키 중복'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => '핵심 접두사'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 8e34076..440530b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1874,6 +1874,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get map_showOverlaps => 'Herhalingssleutel overlapt'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefix sleutel'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 2a034a7..be65e06 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1900,6 +1900,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get map_showOverlaps => 'Nakładające się klucze przekaźników'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefiks klucza'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b0851fe..efcfad0 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1885,6 +1885,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get map_showOverlaps => 'Sobreposições da Chave Repeater'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Prefixo Chave'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5ae0d11..63b5679 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1889,6 +1889,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_showOverlaps => 'Перекрытия ключа повтора'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Префикс ключа'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 0b885ac..be75989 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1876,6 +1876,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get map_showOverlaps => 'Prekrývanie opakovača kľúča'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Päťciferné predpona'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index fa6c120..4b9518c 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1871,6 +1871,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Predpona ključa'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a4e67f6..ab1047d 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1864,6 +1864,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get map_showOverlaps => 'Repeater-nyckelöverlappningar'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Nyckelprefix'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 00aeeb1..ee32259 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1884,6 +1884,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_showOverlaps => 'Перекриття ключів ретрансляторів'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => 'Префікс ключа'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 051db0e..7ae7266 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1778,6 +1778,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_showOverlaps => '重复键重叠'; + @override + String get map_alwaysShowNames => 'Always show names'; + @override String get map_keyPrefix => '关键字前缀'; diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 005197f..782e78a 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -112,6 +112,9 @@ class AppSettings { final bool mapShowChatNodes; final bool mapShowOtherNodes; final bool mapShowOverlaps; + + /// Show node names at any zoom, instead of only past the zoom threshold. + final bool mapAlwaysShowNames; final double mapTimeFilterHours; // 0 = all time final bool mapKeyPrefixEnabled; final String mapKeyPrefix; @@ -188,6 +191,7 @@ class AppSettings { this.mapShowChatNodes = true, this.mapShowOtherNodes = true, this.mapShowOverlaps = false, + this.mapAlwaysShowNames = false, this.mapTimeFilterHours = 0, // Default to all time this.mapKeyPrefixEnabled = false, this.mapKeyPrefix = '', @@ -252,6 +256,7 @@ class AppSettings { 'map_show_chat_nodes': mapShowChatNodes, 'map_show_other_nodes': mapShowOtherNodes, 'map_show_overlaps': mapShowOverlaps, + 'map_always_show_names': mapAlwaysShowNames, 'map_time_filter_hours': mapTimeFilterHours, 'map_key_prefix_enabled': mapKeyPrefixEnabled, 'map_key_prefix': mapKeyPrefix, @@ -338,6 +343,7 @@ class AppSettings { mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true, mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true, mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false, + mapAlwaysShowNames: json['map_always_show_names'] as bool? ?? false, mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0, mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false, @@ -462,6 +468,7 @@ class AppSettings { bool? mapShowChatNodes, bool? mapShowOtherNodes, bool? mapShowOverlaps, + bool? mapAlwaysShowNames, double? mapTimeFilterHours, bool? mapKeyPrefixEnabled, String? mapKeyPrefix, @@ -510,6 +517,7 @@ class AppSettings { mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes, mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes, mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps, + mapAlwaysShowNames: mapAlwaysShowNames ?? this.mapAlwaysShowNames, mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours, mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled, mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 68cc7dd..6917817 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -29,6 +29,8 @@ import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; +import 'settings_screen.dart'; +import '../utils/dialog_utils.dart'; import '../widgets/app_shell.dart'; import '../widgets/channel_drawer_list.dart'; import '../widgets/mention_autocomplete.dart'; @@ -287,6 +289,11 @@ class _ChannelChatScreenState extends State { ); } + Future _disconnect(BuildContext context) async { + final connector = context.read(); + await showDisconnectDialog(context, connector); + } + /// Bottom-bar navigation out of an open channel. /// /// Tapping Channels returns to the channel list. Tapping Contacts or Map @@ -351,6 +358,13 @@ class _ChannelChatScreenState extends State { currentChannelIndex: _currentChannel.index, onChannelSelected: _switchChannel, ), + // Present here too: the footer is app-level, so it belongs on every + // screen carrying the panel, not just the primary views. + onDisconnect: () => _disconnect(context), + onSettings: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), appBarBuilder: (context, pinned) => AppBar( // Pinned: the panel is already docked, so no hamburger is needed. // Unpinned: this is a pushed route, so Scaffold would put a back arrow diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index f7deb8b..eedfd71 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -97,251 +97,229 @@ class _ChannelsScreenState extends State return const SizedBox.shrink(); } - final allowBack = !connector.isConnected; - - return PopScope( - canPop: allowBack, - child: AppShell( - selectedIndex: 1, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - drawerContent: ChannelDrawerList( - onChannelSelected: (channel) => _openChannel(context, channel), - ), - appBar: AppBar( - title: AppBarTitle(context.l10n.channels_title), - centerTitle: true, - bottom: const SyncProgressAppBarBottom(), - actions: [ + 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.logout, color: Colors.red), + const Icon(Icons.groups), const SizedBox(width: 8), - Text(context.l10n.common_disconnect), + Text(context.l10n.community_manageCommunities), ], ), - onTap: () => _disconnect(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(), - ), - ), + onTap: () => _showManageCommunitiesDialog(context), ), ], icon: const Icon(Icons.more_vert), ), - ], - ), - body: RefreshIndicator( - onRefresh: () async { - await context.read().getChannels(force: true); - }, - child: () { - final channels = connector.channels; - final waitingForFirstChannel = - connector.isLoadingChannels && channels.isEmpty; - - // Only block the list while the first channel is actively loading. - // If the initial sync aborts, show cached/partial channels instead - // of trapping the user behind an idle spinner. - if (waitingForFirstChannel) { - return const Center(child: CircularProgressIndicator()); - } + ], + ), + body: RefreshIndicator( + onRefresh: () async { + await context.read().getChannels(force: true); + }, + child: () { + final channels = connector.channels; + final waitingForFirstChannel = + connector.isLoadingChannels && channels.isEmpty; + + // Only block the list while the first channel is actively loading. + // If the initial sync aborts, show cached/partial channels instead + // of trapping the user behind an idle spinner. + if (waitingForFirstChannel) { + return const Center(child: CircularProgressIndicator()); + } - if (channels.isEmpty) { - return ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 200, - child: EmptyState( - icon: Icons.tag, - title: context.l10n.channels_noChannelsConfigured, - action: FilledButton.icon( - onPressed: () => _addPublicChannel(context, connector), - icon: const Icon(Icons.public), - label: Text(context.l10n.channels_addPublicChannel), - ), + if (channels.isEmpty) { + return ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height - 200, + child: EmptyState( + icon: Icons.tag, + title: context.l10n.channels_noChannelsConfigured, + action: FilledButton.icon( + onPressed: () => _addPublicChannel(context, connector), + icon: const Icon(Icons.public), + label: Text(context.l10n.channels_addPublicChannel), ), ), - ], - ); - } - - final filteredChannels = _filterAndSortChannels( - channels, - connector, - viewState, + ), + ], ); + } - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.channels_searchChannels, - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (viewState.channelsSearchText.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchDebounce?.cancel(); - _searchDebounce = null; - _searchController.clear(); - context - .read() - .setChannelsSearchText(''); - }, - ), - _buildFilterButton(viewState), - ], - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + final filteredChannels = _filterAndSortChannels( + channels, + connector, + viewState, + ); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.channels_searchChannels, + prefixIcon: const Icon(Icons.search), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (viewState.channelsSearchText.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchDebounce?.cancel(); + _searchDebounce = null; + _searchController.clear(); + context + .read() + .setChannelsSearchText(''); + }, + ), + _buildFilterButton(viewState), + ], + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - onChanged: (value) { - _searchDebounce?.cancel(); - _searchDebounce = Timer( - const Duration(milliseconds: 300), - () { - if (!mounted) return; - context - .read() - .setChannelsSearchText(value); - }, - ); - }, ), + onChanged: (value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 300), + () { + if (!mounted) return; + context + .read() + .setChannelsSearchText(value); + }, + ); + }, ), - Expanded( - child: filteredChannels.isEmpty - ? ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 300, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - context.l10n.channels_noChannelsFound, - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), + ), + Expanded( + child: filteredChannels.isEmpty + ? ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height - 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + context.l10n.channels_noChannelsFound, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], ), - ], - ), + ), + ], ), ), - ], - ) - : (viewState.channelsSortOption == - ChannelSortOption.manual && - viewState.channelsSearchText.isEmpty) - ? ReorderableListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, ), - buildDefaultDragHandles: false, - itemCount: filteredChannels.length, - onReorderItem: (oldIndex, newIndex) { - // onReorderItem already adjusts newIndex after the - // removed item, unlike the deprecated onReorder. - final reordered = List.from( - filteredChannels, - ); - final item = reordered.removeAt(oldIndex); - reordered.insert(newIndex, item); - unawaited( - connector.setChannelOrder( - reordered.map((c) => c.index).toList(), - ), - ); - }, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - showDragHandle: true, - dragIndex: index, - ); - }, - ) - : ListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, - ), - itemCount: filteredChannels.length, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - ); - }, + ], + ) + : (viewState.channelsSortOption == + ChannelSortOption.manual && + viewState.channelsSearchText.isEmpty) + ? ReorderableListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, ), - ), - ], - ); - }(), - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showAddChannelDialog(context), - tooltip: context.l10n.channels_addChannel, - child: const Icon(Icons.add), - ), + buildDefaultDragHandles: false, + itemCount: filteredChannels.length, + onReorderItem: (oldIndex, newIndex) { + // onReorderItem already adjusts newIndex after the + // removed item, unlike the deprecated onReorder. + final reordered = List.from( + filteredChannels, + ); + final item = reordered.removeAt(oldIndex); + reordered.insert(newIndex, item); + unawaited( + connector.setChannelOrder( + reordered.map((c) => c.index).toList(), + ), + ); + }, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + showDragHandle: true, + dragIndex: index, + ); + }, + ) + : ListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, + ), + itemCount: filteredChannels.length, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + ); + }, + ), + ), + ], + ); + }(), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddChannelDialog(context), + tooltip: context.l10n.channels_addChannel, + child: const Icon(Icons.add), ), ); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 87fc7e4..ce32f4c 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -317,125 +317,103 @@ class _ContactsScreenState extends State return const SizedBox.shrink(); } - final allowBack = !connector.isConnected; - return PopScope( - canPop: allowBack, - child: AppShell( - selectedIndex: 0, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - drawerContent: const ContactFilterRail(), - 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), - ), - }, - ), - 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), - ), - }, + 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.copy), - const SizedBox(width: 8), - Text(context.l10n.contacts_copyAdvertToClipboard), - ], + onTap: () => { + connector.sendSelfAdvert(flood: false), + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_advertisementSent), ), - onTap: () => _contactExport(Uint8List.fromList([])), + }, + ), + 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.paste), - const SizedBox(width: 8), - Text(context.l10n.contacts_addContactFromClipboard), - ], + onTap: () => { + connector.sendSelfAdvert(flood: true), + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_advertisementSent), ), - onTap: () => _contactImport(), + }, + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.copy), + const SizedBox(width: 8), + Text(context.l10n.contacts_copyAdvertToClipboard), + ], ), - ], - icon: const Icon(Icons.connect_without_contact), - ), - PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.logout, color: Colors.red), - const SizedBox(width: 8), - Text(context.l10n.common_disconnect), - ], - ), - onTap: () => _disconnect(context, connector), + onTap: () => _contactExport(Uint8List.fromList([])), + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.paste), + const SizedBox(width: 8), + Text(context.l10n.contacts_addContactFromClipboard), + ], ), - 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), + ], ), - 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(), - ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DiscoveryScreen(), ), ), - ], - icon: const Icon(Icons.more_vert), - ), - ], - ), - body: _buildContactsBody(context, connector), + ), + ], + icon: const Icon(Icons.more_vert), + ), + ], ), + body: _buildContactsBody(context, connector), ); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1444f5a..2b9ade1 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -24,6 +24,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; import '../widgets/app_shell.dart'; +import '../widgets/map_layer_panel.dart'; import '../widgets/sync_progress_overlay.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; @@ -398,7 +399,8 @@ class _MapScreenState extends State { // Re center map after removed markers have loaded if (!_hasInitializedMap && _removedMarkersLoaded) { _hasInitializedMap = true; - _showNodeLabels = initialZoom >= _labelZoomThreshold; + _showNodeLabels = + settings.mapAlwaysShowNames || initialZoom >= _labelZoomThreshold; if (hasMapContent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -408,281 +410,253 @@ class _MapScreenState extends State { } } - final allowBack = !connector.isConnected; - - return PopScope( - canPop: allowBack, - child: AppShell( - selectedIndex: 2, - onDestinationSelected: (index) => - _handleQuickSwitch(index, context), - contactsUnreadCount: connector.getTotalContactsUnreadCount(), - channelsUnreadCount: connector.getTotalChannelsUnreadCount(), - appBar: AppBar( - title: AppBarTitle(context.l10n.map_title), - centerTitle: true, - bottom: const SyncProgressAppBarBottom(), - actions: [ - if (!_isBuildingPathTrace) - IconButton( - icon: const Icon(Icons.radar), - onPressed: () => _startPath( - LatLng(connector.selfLatitude!, connector.selfLongitude!), - ), - tooltip: context.l10n.contacts_pathTrace, + return AppShell( + selectedIndex: 2, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), + drawerContent: const MapLayerPanel(), + onDisconnect: () => _disconnect(context, connector), + onSettings: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), + appBar: AppBar( + title: AppBarTitle(context.l10n.map_title), + centerTitle: true, + bottom: const SyncProgressAppBarBottom(), + actions: [ + if (!_isBuildingPathTrace) + IconButton( + icon: const Icon(Icons.radar), + onPressed: () => _startPath( + LatLng(connector.selfLatitude!, connector.selfLongitude!), ), - if (!_isBuildingPathTrace) - IconButton( - icon: const LosIcon(), - onPressed: () { - final candidates = []; - if (connector.selfLatitude != null && - connector.selfLongitude != null) { - candidates.add( - LineOfSightEndpoint( - label: context.l10n.pathTrace_you, - point: LatLng( - connector.selfLatitude!, - connector.selfLongitude!, - ), - color: Colors.teal, - icon: Icons.person_pin_circle, - ), - ); - } - for (final c in contactsWithLocation) { - candidates.add( - LineOfSightEndpoint( - label: c.name, - point: LatLng(c.latitude!, c.longitude!), - color: _getNodeColor(c.type), - icon: _getNodeIcon(c.type), - ), - ); - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LineOfSightMapScreen( - title: context.l10n.map_losScreenTitle, - candidates: candidates, + tooltip: context.l10n.contacts_pathTrace, + ), + if (!_isBuildingPathTrace) + IconButton( + icon: const LosIcon(), + onPressed: () { + final candidates = []; + if (connector.selfLatitude != null && + connector.selfLongitude != null) { + candidates.add( + LineOfSightEndpoint( + label: context.l10n.pathTrace_you, + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, ), + color: Colors.teal, + icon: Icons.person_pin_circle, ), ); - }, - tooltip: context.l10n.map_lineOfSight, - ), - PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.logout, color: Colors.red), - const SizedBox(width: 8), - Text(context.l10n.common_disconnect), - ], - ), - onTap: () => _disconnect(context, connector), - ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.settings), - const SizedBox(width: 8), - Text(context.l10n.settings_title), - ], - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsScreen(), + } + 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, ), ), - ), - ], - icon: const Icon(Icons.more_vert), + ); + }, + 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(), - ), - 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( + ], + ), + 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', ); - }, - onPositionChanged: (camera, hasGesture) { - final shouldShow = camera.zoom >= _labelZoomThreshold; - if (shouldShow != _showNodeLabels && mounted) { - setState(() { - _showNodeLabels = shouldShow; - }); - } - }, + } + }, + onLongPress: (_, latLng) { + if (_isSelectingPoi) { + setState(() { + _isSelectingPoi = false; + }); + _shareMarker( + context: context, + connector: connector, + position: latLng, + defaultLabel: context.l10n.map_pointOfInterest, + flags: 'poi', + ); + return; + } + _showShareMarkerAtPositionSheet( + context: context, + connector: connector, + position: latLng, + ); + }, + onPositionChanged: (camera, hasGesture) { + final shouldShow = + settings.mapAlwaysShowNames || + camera.zoom >= _labelZoomThreshold; + if (shouldShow != _showNodeLabels && mounted) { + setState(() { + _showNodeLabels = shouldShow; + }); + } + }, + ), + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: + MapTileCacheService.userAgentPackageName, + maxZoom: 19, ), - children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, - ), - if (_polylines.isNotEmpty && _isBuildingPathTrace) - PolylineLayer(polylines: _polylines), - if (sharedMarkerPolylines.isNotEmpty) - PolylineLayer(polylines: sharedMarkerPolylines), - MarkerLayer( - markers: [ - if (highlightPosition != null) - Marker( - point: highlightPosition, - width: 40, - height: 40, - child: IgnorePointer( - child: Icon( - Icons.location_on_outlined, - color: Colors.red[600], - size: 34, - ), + if (_polylines.isNotEmpty && _isBuildingPathTrace) + PolylineLayer(polylines: _polylines), + if (sharedMarkerPolylines.isNotEmpty) + PolylineLayer(polylines: sharedMarkerPolylines), + MarkerLayer( + markers: [ + if (highlightPosition != null) + Marker( + point: highlightPosition, + width: 40, + height: 40, + child: IgnorePointer( + child: Icon( + Icons.location_on_outlined, + color: Colors.red[600], + size: 34, ), ), - if (!settings.mapShowOverlaps) - ..._buildGuessedMarker( - guessedLocations, - showLabels: _showNodeLabels, - ), - ..._buildMarkers( - contactsWithLocation, - settings, - showLabels: _showNodeLabels, ), - ...sharedMarkers.map(_buildSharedMarker), - if (connector.selfLatitude != null && - connector.selfLongitude != null) - Marker( - point: LatLng( - connector.selfLatitude!, - connector.selfLongitude!, - ), - width: 40, - height: 40, - child: IgnorePointer( - ignoring: true, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.teal, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues( - alpha: 0.3, - ), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: const Icon( - Icons.person_pin_circle, + if (!settings.mapShowOverlaps) + ..._buildGuessedMarker( + guessedLocations, + showLabels: + settings.mapAlwaysShowNames || _showNodeLabels, + ), + ..._buildMarkers( + contactsWithLocation, + settings, + showLabels: + settings.mapAlwaysShowNames || _showNodeLabels, + ), + ...sharedMarkers.map(_buildSharedMarker), + if (connector.selfLatitude != null && + connector.selfLongitude != null) + Marker( + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, + ), + width: 40, + height: 40, + child: IgnorePointer( + ignoring: true, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.teal, + shape: BoxShape.circle, + border: Border.all( color: Colors.white, - size: 20, + width: 2, ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: const Icon( + Icons.person_pin_circle, + color: Colors.white, + size: 20, ), ), ), - if (_showNodeLabels && - connector.selfLatitude != null && - connector.selfLongitude != null) - _buildNodeLabelMarker( - point: LatLng( - connector.selfLatitude!, - connector.selfLongitude!, - ), - label: context.l10n.pathTrace_you, + ), + if (_showNodeLabels && + connector.selfLatitude != null && + connector.selfLongitude != null) + _buildNodeLabelMarker( + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, ), - ], - ), - ], - ), - if (!_isBuildingPathTrace) - _buildLegend( - contacts, - contactsWithLocation, - settings, - sharedMarkers.length, - guessedLocations.length, - ), - if (isDesktop) - _buildDesktopMapControls( - context, - center: center, - zoom: initialZoom, - hasPathSelector: _isBuildingPathTrace, + label: context.l10n.pathTrace_you, + ), + ], ), - if (_isBuildingPathTrace) _buildPathTraceOverlay(), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showFilterDialog(context, settingsService), - tooltip: context.l10n.map_filterNodes, - child: const Icon(Icons.filter_list), - ), + ], + ), + if (!_isBuildingPathTrace) + _buildLegend( + contacts, + contactsWithLocation, + settings, + sharedMarkers.length, + guessedLocations.length, + ), + if (isDesktop) + _buildDesktopMapControls( + context, + center: center, + zoom: initialZoom, + hasPathSelector: _isBuildingPathTrace, + ), + if (_isBuildingPathTrace) _buildPathTraceOverlay(), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showFilterDialog(context, settingsService), + tooltip: context.l10n.map_filterNodes, + child: const Icon(Icons.filter_list), ), ); }, diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 3222168..e2f635a 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -25,6 +25,11 @@ class ScannerScreen extends StatefulWidget { class _ScannerScreenState extends State { bool _changedNavigation = false; + + /// Re-entrancy guard for routing into the app. Distinct from + /// [_changedNavigation], which records that we entered at least once and + /// gates the disconnect-on-dispose cleanup. + bool _routingIntoApp = false; late final MeshCoreConnector _connector; late final VoidCallback _connectionListener; BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown; @@ -40,14 +45,28 @@ class _ScannerScreenState extends State { if (_connector.state == MeshCoreConnectionState.disconnected) { _changedNavigation = false; } else if (_connector.state == MeshCoreConnectionState.connected && - _connector.activeTransport == MeshCoreTransportType.bluetooth && isCurrentRoute && - !_changedNavigation) { + !_routingIntoApp) { + // Route in whenever this screen is showing while connected, not just + // on the first connect. Otherwise landing back here with a live + // connection is a dead end: Connect does nothing, because there is + // nothing left to connect. + // + // Deliberately transport-agnostic. This was bluetooth-only, which left + // the same dead end for USB and TCP users, who cannot connect their way + // out of it either. The first-connect path for those transports is not + // affected: their own screen sits on top while connecting, so + // isCurrentRoute is false here and they still navigate themselves. _changedNavigation = true; + _routingIntoApp = true; if (mounted) { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ChannelsScreen()), - ); + Navigator.of(context) + .push( + MaterialPageRoute(builder: (context) => const ChannelsScreen()), + ) + .then((_) { + if (mounted) _routingIntoApp = false; + }); } } }; diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 5e3d8c3..d0ea235 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -76,6 +76,10 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(mapShowOverlaps: value)); } + Future setMapAlwaysShowNames(bool value) async { + await updateSettings(_settings.copyWith(mapAlwaysShowNames: value)); + } + Future setMapTimeFilterHours(double value) async { await updateSettings(_settings.copyWith(mapTimeFilterHours: value)); } diff --git a/lib/utils/app_backgrounder.dart b/lib/utils/app_backgrounder.dart new file mode 100644 index 0000000..2a01908 --- /dev/null +++ b/lib/utils/app_backgrounder.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; + +import 'platform_info.dart'; + +/// Sends the app to the background without tearing it down. +/// +/// `SystemNavigator.pop()` is NOT this: on Android it calls `finish()` on the +/// activity, which destroys the Flutter engine and drops the radio connection, +/// so reopening the app finds itself disconnected. `moveTaskToBack` leaves the +/// activity alive and simply hands focus back to whatever was in front before. +class AppBackgrounder { + static const MethodChannel _channel = MethodChannel( + 'meshcore_open/app_lifecycle', + ); + + /// Returns true when the app was actually backgrounded. + /// + /// Android only. Desktop windows are closed by their own chrome and iOS + /// forbids programmatic backgrounding, so elsewhere this is a no-op and the + /// caller should leave the press unhandled rather than act on it. + static Future moveToBackground() async { + if (!PlatformInfo.isAndroid) return false; + try { + return await _channel.invokeMethod('moveTaskToBack') ?? false; + } on PlatformException catch (_) { + // Surface nothing to the user: the fallback is simply that back does + // nothing at the root, which is the pre-existing behaviour. + return false; + } on MissingPluginException catch (_) { + return false; + } + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index cfc8ab9..d07c3ec 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../l10n/l10n.dart'; import '../services/ui_view_state_service.dart'; +import '../utils/app_backgrounder.dart'; import 'quick_switch_bar.dart'; /// Shared shell for the primary views (Contacts / Channels / Map). @@ -11,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; @@ -36,6 +38,12 @@ class AppShell extends StatelessWidget { /// List content for the active view. Null renders an empty drawer body. final Widget? drawerContent; + /// App-level actions, pinned to the bottom of the panel. These are not + /// contextual, which is why they were duplicated in three screens' overflow + /// menus before. Screen-level actions stay in their own overflow menu. + final VoidCallback? onDisconnect; + final VoidCallback? onSettings; + const AppShell({ super.key, required this.body, @@ -45,48 +53,98 @@ class AppShell extends StatelessWidget { this.appBarBuilder, this.floatingActionButton, this.drawerContent, + this.onDisconnect, + this.onSettings, this.contactsUnreadCount = 0, this.channelsUnreadCount = 0, }); + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + /// System back, in priority order: + /// 1. an open drawer closes, + /// 2. 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 _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 Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth >= wideBreakpoint; - final pinned = - isWide && context.watch().navDrawerPinned; - - return pinned - ? _buildPinned(context) - : _buildTransient(context, isWide); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _handleBack(); }, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= AppShell.wideBreakpoint; + final pinned = + isWide && context.watch().navDrawerPinned; + + return pinned + ? _buildPinned(context) + : _buildTransient(context, isWide); + }, + ), ); } /// Null on detail screens, which show the panel but no bottom bar. Widget? _bottomBar() { - final index = selectedIndex; - final onSelected = onDestinationSelected; + final index = widget.selectedIndex; + final onSelected = widget.onDestinationSelected; if (index == null || onSelected == null) return null; return SafeArea( top: false, child: QuickSwitchBar( selectedIndex: index, onDestinationSelected: onSelected, - contactsUnreadCount: contactsUnreadCount, - channelsUnreadCount: channelsUnreadCount, + contactsUnreadCount: widget.contactsUnreadCount, + channelsUnreadCount: widget.channelsUnreadCount, ), ); } PreferredSizeWidget? _appBar(BuildContext context, bool pinned) { - return appBarBuilder?.call(context, pinned) ?? appBar; + return widget.appBarBuilder?.call(context, pinned) ?? widget.appBar; } /// Wide + pinned: the panel is laid out beside the body, not overlaid. Widget _buildPinned(BuildContext context) { return Scaffold( + key: _scaffoldKey, appBar: _appBar(context, true), body: SafeArea( top: false, @@ -94,15 +152,20 @@ class AppShell extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( - width: _drawerWidth, - child: _NavPanel(isWide: true, content: drawerContent), + width: AppShell._drawerWidth, + child: _NavPanel( + isWide: true, + 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(), ); } @@ -111,13 +174,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, - child: _NavPanel(isWide: isWide, content: drawerContent), + width: AppShell._drawerWidth, + child: _NavPanel( + isWide: isWide, + content: widget.drawerContent, + onDisconnect: widget.onDisconnect, + onSettings: widget.onSettings, + ), ), - body: body, - floatingActionButton: floatingActionButton, + body: widget.body, + floatingActionButton: widget.floatingActionButton, bottomNavigationBar: _bottomBar(), ); } @@ -128,8 +197,15 @@ class AppShell extends StatelessWidget { class _NavPanel extends StatelessWidget { final bool isWide; 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 Widget build(BuildContext context) { @@ -164,6 +240,51 @@ class _NavPanel extends StatelessWidget { ), if (isWide) const Divider(height: 1), Expanded(child: content ?? const SizedBox.shrink()), + if (onDisconnect != null || onSettings != null) ...[ + const Divider(height: 1), + _Footer(onDisconnect: onDisconnect, onSettings: onSettings), + ], + ], + ), + ); + } +} + +/// App-level actions pinned to the bottom of the panel. +class _Footer extends StatelessWidget { + final VoidCallback? onDisconnect; + final VoidCallback? onSettings; + + const _Footer({this.onDisconnect, this.onSettings}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 8), + child: Row( + children: [ + if (onDisconnect != null) + Expanded( + child: TextButton.icon( + // Kept visually distinct: this drops the radio connection, + // and it was a red menu entry before the move. + style: TextButton.styleFrom(foregroundColor: colors.error), + icon: const Icon(Icons.logout, size: 18), + label: Text(l10n.common_disconnect), + onPressed: onDisconnect, + ), + ), + if (onSettings != null) + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.settings, size: 18), + label: Text(l10n.settings_title), + onPressed: onSettings, + ), + ), ], ), ); diff --git a/lib/widgets/map_layer_panel.dart b/lib/widgets/map_layer_panel.dart new file mode 100644 index 0000000..29c18ea --- /dev/null +++ b/lib/widgets/map_layer_panel.dart @@ -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(); + 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 onChanged, + }) { + return SwitchListTile( + dense: true, + secondary: Icon(icon, size: 20), + title: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis), + value: value, + onChanged: onChanged, + ); + } +}