From a26ae96acdc1744d476de1ba2088c1b40bc7aa97 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 22 Mar 2026 18:06:24 +0100 Subject: [PATCH] feat: Add repeater discovery onboarding flow --- lib/l10n/app_en.arb | 20 ++- lib/l10n/app_localizations.dart | 24 ++- lib/l10n/app_localizations_de.dart | 7 + lib/l10n/app_localizations_el.dart | 7 + lib/l10n/app_localizations_en.dart | 19 +- lib/l10n/app_localizations_es.dart | 7 + lib/l10n/app_localizations_fr.dart | 7 + lib/l10n/app_localizations_hr.dart | 7 + lib/l10n/app_localizations_it.dart | 7 + lib/l10n/app_localizations_pl.dart | 7 + lib/l10n/app_localizations_pt.dart | 7 + lib/l10n/app_localizations_ru.dart | 7 + lib/l10n/app_localizations_sl.dart | 7 + lib/l10n/app_localizations_tr.dart | 7 + lib/l10n/app_localizations_uk.dart | 7 + lib/l10n/app_localizations_zh.dart | 7 + lib/l10n/untranslated.json | 67 ++++++- lib/screens/discovery_screen.dart | 25 ++- lib/screens/home_screen.dart | 8 +- lib/screens/welcome_wizard_screen.dart | 9 +- lib/widgets/connection_dialog.dart | 216 +++++++++++++++-------- test/screens/discovery_screen_test.dart | 18 +- test/widgets/connection_dialog_test.dart | 67 +++++++ 23 files changed, 458 insertions(+), 106 deletions(-) create mode 100644 test/widgets/connection_dialog_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d3c6985..a71dcfd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2285,7 +2285,7 @@ "@change": { "description": "Generic change action" }, - "wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.", + "wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.", "@wizardOverviewDescription": { "description": "Updated onboarding overview description" }, @@ -2297,7 +2297,7 @@ "@wizardOverviewFeature2": { "description": "Updated onboarding overview feature 2" }, - "wizardOverviewFeature3": "Connect over BLE or TCP, then manage the companion radio from inside the app.", + "wizardOverviewFeature3": "Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.", "@wizardOverviewFeature3": { "description": "Updated onboarding overview feature 3" }, @@ -2410,11 +2410,11 @@ "@wizardNetworkTitle": { "description": "Updated onboarding network page title" }, - "wizardNetworkDescription": "The Contacts tab organizes the network you discover and the routes you learn over time.", + "wizardNetworkDescription": "The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.", "@wizardNetworkDescription": { "description": "Updated onboarding network page description" }, - "wizardNetworkFeature1": "Review team members, repeaters, rooms, channels, and pending adverts in one list.", + "wizardNetworkFeature1": "Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.", "@wizardNetworkFeature1": { "description": "Updated onboarding network feature 1" }, @@ -2422,7 +2422,7 @@ "@wizardNetworkFeature2": { "description": "Updated onboarding network feature 2" }, - "wizardNetworkFeature3": "Create channels and manage network destinations without leaving the app.", + "wizardNetworkFeature3": "Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.", "@wizardNetworkFeature3": { "description": "Updated onboarding network feature 3" }, @@ -2462,10 +2462,18 @@ "@wizardToolsFeature2": { "description": "Updated onboarding tools feature 2" }, - "wizardToolsFeature3": "Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.", + "wizardToolsFeature3": "Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.", "@wizardToolsFeature3": { "description": "Updated onboarding tools feature 3" }, + "postConnectDiscoveryTitle": "Discover repeaters now?", + "@postConnectDiscoveryTitle": { + "description": "Title for the prompt shown after a successful device connection" + }, + "postConnectDiscoveryDescription": "Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.", + "@postConnectDiscoveryDescription": { + "description": "Body text for the prompt shown after a successful device connection" + }, "contactInSensors": "In Sensors", "@contactInSensors": { "description": "Contact actions label when already in sensors" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6a09a68..3297b5b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3219,7 +3219,7 @@ abstract class AppLocalizations { /// Updated onboarding overview description /// /// In en, this message translates to: - /// **'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.'** + /// **'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.'** String get wizardOverviewDescription; /// Updated onboarding overview feature 1 @@ -3237,7 +3237,7 @@ abstract class AppLocalizations { /// Updated onboarding overview feature 3 /// /// In en, this message translates to: - /// **'Connect over BLE or TCP, then manage the companion radio from inside the app.'** + /// **'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.'** String get wizardOverviewFeature3; /// Updated onboarding messaging page title @@ -3387,13 +3387,13 @@ abstract class AppLocalizations { /// Updated onboarding network page description /// /// In en, this message translates to: - /// **'The Contacts tab organizes the network you discover and the routes you learn over time.'** + /// **'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.'** String get wizardNetworkDescription; /// Updated onboarding network feature 1 /// /// In en, this message translates to: - /// **'Review team members, repeaters, rooms, channels, and pending adverts in one list.'** + /// **'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.'** String get wizardNetworkFeature1; /// Updated onboarding network feature 2 @@ -3405,7 +3405,7 @@ abstract class AppLocalizations { /// Updated onboarding network feature 3 /// /// In en, this message translates to: - /// **'Create channels and manage network destinations without leaving the app.'** + /// **'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.'** String get wizardNetworkFeature3; /// Updated onboarding map page title @@ -3465,9 +3465,21 @@ abstract class AppLocalizations { /// Updated onboarding tools feature 3 /// /// In en, this message translates to: - /// **'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'** + /// **'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'** String get wizardToolsFeature3; + /// Title for the prompt shown after a successful device connection + /// + /// In en, this message translates to: + /// **'Discover repeaters now?'** + String get postConnectDiscoveryTitle; + + /// Body text for the prompt shown after a successful device connection + /// + /// In en, this message translates to: + /// **'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'** + String get postConnectDiscoveryDescription; + /// Contact actions label when already in sensors /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2cf2288..4f995ed 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1898,6 +1898,13 @@ class AppLocalizationsDe extends AppLocalizations { String get wizardToolsFeature3 => 'Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'In Sensoren'; diff --git a/lib/l10n/app_localizations_el.dart b/lib/l10n/app_localizations_el.dart index 911f7fa..e247062 100644 --- a/lib/l10n/app_localizations_el.dart +++ b/lib/l10n/app_localizations_el.dart @@ -1905,6 +1905,13 @@ class AppLocalizationsEl extends AppLocalizations { String get wizardToolsFeature3 => 'Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'Στους αισθητήρες'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d0041f8..6aa0921 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1717,7 +1717,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wizardOverviewDescription => - 'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.'; + 'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.'; @override String get wizardOverviewFeature1 => @@ -1729,7 +1729,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wizardOverviewFeature3 => - 'Connect over BLE or TCP, then manage the companion radio from inside the app.'; + 'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.'; @override String get wizardMessagingTitle => 'Messaging and Field Reports'; @@ -1819,11 +1819,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wizardNetworkDescription => - 'The Contacts tab organizes the network you discover and the routes you learn over time.'; + 'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.'; @override String get wizardNetworkFeature1 => - 'Review team members, repeaters, rooms, channels, and pending adverts in one list.'; + 'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.'; @override String get wizardNetworkFeature2 => @@ -1831,7 +1831,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wizardNetworkFeature3 => - 'Create channels and manage network destinations without leaving the app.'; + 'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.'; @override String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry'; @@ -1869,7 +1869,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wizardToolsFeature3 => - 'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'; + 'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'; + + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; @override String get contactInSensors => 'In Sensors'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 33b37db..256c762 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1901,6 +1901,13 @@ class AppLocalizationsEs extends AppLocalizations { String get wizardToolsFeature3 => 'Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'En Sensores'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index bd925c7..4aca4b2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1905,6 +1905,13 @@ class AppLocalizationsFr extends AppLocalizations { String get wizardToolsFeature3 => 'Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'Dans Capteurs'; diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index d9c4575..047e7f9 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -1881,6 +1881,13 @@ class AppLocalizationsHr extends AppLocalizations { String get wizardToolsFeature3 => 'Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'U Senzorima'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 7f35ce8..48ce635 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1896,6 +1896,13 @@ class AppLocalizationsIt extends AppLocalizations { String get wizardToolsFeature3 => 'Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'Nei Sensori'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index c3812ee..1009305 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1893,6 +1893,13 @@ class AppLocalizationsPl extends AppLocalizations { String get wizardToolsFeature3 => 'Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'W Czujnikach'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 23e46a6..c4b5c8e 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1900,6 +1900,13 @@ class AppLocalizationsPt extends AppLocalizations { String get wizardToolsFeature3 => 'Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'Nos Sensores'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 62394cb..ff5a7a4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1890,6 +1890,13 @@ class AppLocalizationsRu extends AppLocalizations { String get wizardToolsFeature3 => 'Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'В датчиках'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index bebd6e3..e009bce 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1881,6 +1881,13 @@ class AppLocalizationsSl extends AppLocalizations { String get wizardToolsFeature3 => 'Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'V senzorjih'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 2b91348..ec00510 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1883,6 +1883,13 @@ class AppLocalizationsTr extends AppLocalizations { String get wizardToolsFeature3 => 'Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'Sensörlerde'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index a5467d9..23f3896 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1896,6 +1896,13 @@ class AppLocalizationsUk extends AppLocalizations { String get wizardToolsFeature3 => 'Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => 'У датчиках'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index cc851c1..1784942 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1796,6 +1796,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。'; + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + @override String get contactInSensors => '在传感器中'; diff --git a/lib/l10n/untranslated.json b/lib/l10n/untranslated.json index 9e26dfe..1530c86 100644 --- a/lib/l10n/untranslated.json +++ b/lib/l10n/untranslated.json @@ -1 +1,66 @@ -{} \ No newline at end of file +{ + "de": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "el": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "es": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "fr": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "hr": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "it": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "pl": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "pt": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "ru": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "sl": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "tr": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "uk": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ], + + "zh": [ + "postConnectDiscoveryTitle", + "postConnectDiscoveryDescription" + ] +} diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index c0528d2..20707f9 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -12,7 +12,9 @@ import '../widgets/compact_signal_indicator.dart' show SignalMetric; enum _DiscoveryListFilter { all, repeaters, sensors, others } class DiscoveryScreen extends StatefulWidget { - const DiscoveryScreen({super.key}); + final bool autoDiscoverRepeatersOnOpen; + + const DiscoveryScreen({super.key, this.autoDiscoverRepeatersOnOpen = false}); @override State createState() => _DiscoveryScreenState(); @@ -25,6 +27,7 @@ class _DiscoveryScreenState extends State { final Set _runningDiscoveryTypes = {}; final TextEditingController _searchController = TextEditingController(); bool _isResolvingAll = false; + bool _hasQueuedAutoRepeaterDiscovery = false; String _searchQuery = ''; _DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all; late final Future> _cachedNodesFuture; @@ -35,6 +38,13 @@ class _DiscoveryScreenState extends State { _cachedNodesFuture = MeshMapNodesService.loadCachedNodes( cacheTtl: MeshMapNodesService.traceCacheTtl, ); + + if (widget.autoDiscoverRepeatersOnOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _autoDiscoverRepeaters(); + }); + } } @override @@ -96,9 +106,10 @@ class _DiscoveryScreenState extends State { advertType: advertType, ); if (!mounted) return; + final l10n = AppLocalizations.of(context)!; final label = switch (advertType) { - _repeaterAdvertType => 'Repeater discovery sent', - _sensorAdvertType => 'Sensor discovery sent', + _repeaterAdvertType => l10n.repeaterDiscoverySent, + _sensorAdvertType => l10n.sensorDiscoverySent, _ => 'Discovery sent', }; ScaffoldMessenger.of( @@ -113,6 +124,14 @@ class _DiscoveryScreenState extends State { } } + Future _autoDiscoverRepeaters() async { + if (_hasQueuedAutoRepeaterDiscovery) { + return; + } + _hasQueuedAutoRepeaterDiscovery = true; + await _discoverNodeType(_repeaterAdvertType); + } + Future _resolveAdvert(PendingAdvert advert) async { final keyHex = advert.publicKeyHex; if (_resolvingAdvertKeys.contains(keyHex)) return; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4e8d8c6..afae545 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -737,12 +737,10 @@ class _HomeScreenState extends State } } - void _showConnectionDialog(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, + Future _showConnectionDialog(BuildContext context) async { + await showConnectionDialogFlow( + context, backgroundColor: Colors.transparent, - builder: (context) => const ConnectionDialog(), ); } diff --git a/lib/screens/welcome_wizard_screen.dart b/lib/screens/welcome_wizard_screen.dart index 01d9fd3..c99a076 100644 --- a/lib/screens/welcome_wizard_screen.dart +++ b/lib/screens/welcome_wizard_screen.dart @@ -221,14 +221,13 @@ class _WelcomeWizardScreenState extends State { } Future _openConnectionDialog() async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => const ConnectionDialog(), + final connected = await showConnectionDialogFlow( + context, + offerPostConnectRepeaterDiscovery: true, ); if (!mounted) return; final connectionProvider = context.read(); - if (connectionProvider.deviceInfo.isConnected) { + if (connected) { await connectionProvider.refreshDeviceInfo(); } if (!mounted) return; diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index c6cf227..8218a07 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -4,10 +4,14 @@ import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../screens/discovery_screen.dart'; import '../services/network_scanner_service.dart'; import '../services/profile_workspace_coordinator.dart'; import '../services/serial/serial_transport.dart'; +enum _ConnectionDialogResult { connected } + Future _initializeConnectedWorkspace({ required ProfileWorkspaceCoordinator profileWorkspaceCoordinator, required AppProvider appProvider, @@ -16,6 +20,64 @@ Future _initializeConnectedWorkspace({ await appProvider.initialize(); } +Future showConnectionDialogFlow( + BuildContext context, { + Color? backgroundColor, + bool offerPostConnectRepeaterDiscovery = false, +}) async { + final result = await showModalBottomSheet<_ConnectionDialogResult>( + context: context, + isScrollControlled: true, + backgroundColor: backgroundColor, + builder: (context) => const ConnectionDialog(), + ); + + if (result != _ConnectionDialogResult.connected || !context.mounted) { + return result == _ConnectionDialogResult.connected; + } + + if (!offerPostConnectRepeaterDiscovery) { + return true; + } + + final contactsProvider = context.read(); + if (contactsProvider.repeaters.isNotEmpty) { + return true; + } + + final l10n = AppLocalizations.of(context)!; + final openDiscovery = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.postConnectDiscoveryTitle), + content: Text(l10n.postConnectDiscoveryDescription), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.continue_), + ), + FilledButton.icon( + onPressed: () => Navigator.of(dialogContext).pop(true), + icon: const Icon(Icons.router_outlined), + label: Text(l10n.discoverRepeaters), + ), + ], + ), + ); + + if (openDiscovery != true || !context.mounted) { + return true; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true), + ), + ); + return true; +} + /// Connection Dialog with tabs for BLE devices and Network servers class ConnectionDialog extends StatefulWidget { const ConnectionDialog({super.key}); @@ -33,6 +95,7 @@ class _ConnectionDialogState extends State int _scannedCount = 0; int _totalToScan = 0; int _lastTabIndex = 0; + bool _hasRequestedBleScan = false; String? _connectingToServerKey; String? _connectingBleDeviceId; @@ -40,10 +103,6 @@ class _ConnectionDialogState extends State if (_tabController.index == _lastTabIndex) return; _lastTabIndex = _tabController.index; - if (_tabController.index == 0) { - _refreshBleDevices(); - } - if (_tabController.index == 1) { if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { setState(() { @@ -65,11 +124,6 @@ class _ConnectionDialogState extends State listen: false, ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _refreshBleDevices(); - }); - _networkScanner.onServerDiscovered = (server) { if (!mounted) return; setState(() { @@ -110,6 +164,13 @@ class _ConnectionDialogState extends State } Future _refreshBleDevices() async { + if (!_hasRequestedBleScan && mounted) { + setState(() { + _hasRequestedBleScan = true; + }); + } else { + _hasRequestedBleScan = true; + } await _connectionProvider.stopScan(); if (!mounted) return; await _connectionProvider.startScan(); @@ -121,6 +182,45 @@ class _ConnectionDialogState extends State return Colors.red; } + Future _handleSuccessfulConnection() async { + final appProvider = context.read(); + final profileWorkspaceCoordinator = context + .read(); + + await _initializeConnectedWorkspace( + profileWorkspaceCoordinator: profileWorkspaceCoordinator, + appProvider: appProvider, + ); + + if (!mounted) return; + Navigator.of(context).pop(_ConnectionDialogResult.connected); + } + + String _normalizeConnectionError(Object error) { + var message = error.toString(); + if (message.startsWith('Exception: ')) { + message = message.substring('Exception: '.length); + } + if (message.startsWith('Connection failed: Exception: ')) { + return message.substring('Connection failed: Exception: '.length); + } + if (message.startsWith('Connection failed: ')) { + return message.substring('Connection failed: '.length); + } + return message; + } + + void _showConnectionError(Object error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_normalizeConnectionError(error)), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + ), + ); + } + @override Widget build(BuildContext context) { final connectionProvider = context.watch(); @@ -401,11 +501,13 @@ class _ConnectionDialogState extends State } Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { + final l10n = AppLocalizations.of(context)!; + return Column( children: [ _buildSectionBanner( icon: Icons.bluetooth_searching_rounded, - message: AppLocalizations.of(context)!.defaultPinInfo, + message: l10n.defaultPinInfo, onRefresh: _refreshBleDevices, ), if (connectionProvider.error != null) @@ -418,8 +520,10 @@ class _ConnectionDialogState extends State : connectionProvider.scannedDevices.isEmpty ? _buildEmptyState( icon: Icons.bluetooth_searching_rounded, - title: AppLocalizations.of(context)!.noDevicesFound, - actionLabel: AppLocalizations.of(context)!.scanAgain, + title: _hasRequestedBleScan + ? l10n.noDevicesFound + : 'Press scan to search for nearby devices', + actionLabel: _hasRequestedBleScan ? l10n.scanAgain : 'Scan', onAction: _refreshBleDevices, ) : ListView.builder( @@ -434,25 +538,25 @@ class _ConnectionDialogState extends State final isConnecting = _connectingBleDeviceId == deviceId; Future connectBle() async { - final appProvider = context.read(); - final profileWorkspaceCoordinator = context - .read(); setState(() { _connectingBleDeviceId = deviceId; }); try { - Navigator.pop(context); final success = await connectionProvider.connect( device, ); - if (success && - connectionProvider.deviceInfo.isConnected) { - await _initializeConnectedWorkspace( - profileWorkspaceCoordinator: - profileWorkspaceCoordinator, - appProvider: appProvider, + if (!success) { + final name = device.platformName.isNotEmpty + ? device.platformName + : 'device'; + throw Exception( + connectionProvider.error ?? + 'Failed to connect to $name', ); } + await _handleSuccessfulConnection(); + } catch (error) { + _showConnectionError(error); } finally { if (mounted) { setState(() { @@ -547,11 +651,6 @@ class _ConnectionDialogState extends State Future connectServer() async { final connectionProvider = context .read(); - final appProvider = context.read(); - final profileWorkspaceCoordinator = context - .read(); - final navigator = Navigator.of(context); - final messenger = ScaffoldMessenger.of(context); setState(() { _connectingToServerKey = serverKey; @@ -567,52 +666,23 @@ class _ConnectionDialogState extends State ); } - await connectionProvider.connectTcp( + final success = await connectionProvider.connectTcp( server.ipAddress, server.port, ); - await _initializeConnectedWorkspace( - profileWorkspaceCoordinator: - profileWorkspaceCoordinator, - appProvider: appProvider, - ); - - if (mounted) { - navigator.pop(); + if (!success) { + throw Exception( + connectionProvider.error ?? + 'Failed to connect to ${server.ipAddress}:${server.port}', + ); } + await _handleSuccessfulConnection(); } catch (e) { if (!mounted) return; setState(() { _connectingToServerKey = null; }); - - var errorMessage = e.toString(); - if (errorMessage.startsWith('Exception: ')) { - errorMessage = errorMessage.substring( - 'Exception: '.length, - ); - } - if (errorMessage.startsWith( - 'Connection failed: Exception: ', - )) { - errorMessage = errorMessage.substring( - 'Connection failed: Exception: '.length, - ); - } else if (errorMessage.startsWith( - 'Connection failed: ', - )) { - errorMessage = errorMessage.substring( - 'Connection failed: '.length, - ); - } - - messenger.showSnackBar( - SnackBar( - content: Text(errorMessage), - backgroundColor: Colors.red, - duration: const Duration(seconds: 5), - ), - ); + _showConnectionError(e); } } @@ -679,8 +749,10 @@ class _ConnectionDialogState extends State actionLabel: actionLabel, onAction: onAction, ), - onConnected: () { - if (mounted) Navigator.of(context).pop(); + onConnected: (result) { + if (mounted) { + Navigator.of(context).pop(result); + } }, ); } @@ -706,7 +778,7 @@ typedef _EmptyStateBuilder = }); class _SerialDeviceList extends StatefulWidget { - final VoidCallback onConnected; + final ValueChanged<_ConnectionDialogResult> onConnected; final _TransportCardBuilder buildTransportCard; final _EmptyStateBuilder buildEmptyState; @@ -801,9 +873,6 @@ class _SerialDeviceListState extends State<_SerialDeviceList> { setState(() => _isConnecting = true); try { final connectionProvider = context.read(); - final appProvider = context.read(); - final profileWorkspaceCoordinator = context - .read(); final connection = await _transport.connect(device); final success = await connectionProvider.connectSerial( service: connection.service, @@ -815,10 +884,11 @@ class _SerialDeviceListState extends State<_SerialDeviceList> { if (success) { await _initializeConnectedWorkspace( - profileWorkspaceCoordinator: profileWorkspaceCoordinator, - appProvider: appProvider, + profileWorkspaceCoordinator: context + .read(), + appProvider: context.read(), ); - widget.onConnected(); + widget.onConnected(_ConnectionDialogResult.connected); } else { await connection.disconnect(); connection.service.dispose(); diff --git a/test/screens/discovery_screen_test.dart b/test/screens/discovery_screen_test.dart index 10c5b36..5dcf5b7 100644 --- a/test/screens/discovery_screen_test.dart +++ b/test/screens/discovery_screen_test.dart @@ -38,6 +38,7 @@ Future _pumpDiscoveryScreen( WidgetTester tester, { ContactsProvider? contactsProvider, ConnectionProvider? connectionProvider, + DiscoveryScreen screen = const DiscoveryScreen(), }) async { final resolvedContactsProvider = contactsProvider ?? ContactsProvider(); final resolvedConnectionProvider = @@ -56,7 +57,7 @@ Future _pumpDiscoveryScreen( child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const DiscoveryScreen(), + home: screen, ), ), ); @@ -113,6 +114,21 @@ void main() { }, ); + testWidgets('auto discovery can trigger repeater discovery on open', ( + tester, + ) async { + final connectionProvider = _FakeConnectionProvider(isConnected: true); + + await _pumpDiscoveryScreen( + tester, + connectionProvider: connectionProvider, + screen: const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true), + ); + + expect(connectionProvider.discoveredAdvertTypes, [2]); + expect(find.text('Repeater discovery sent'), findsOneWidget); + }); + testWidgets('search and inline type filters narrow discovered nodes', ( tester, ) async { diff --git a/test/widgets/connection_dialog_test.dart b/test/widgets/connection_dialog_test.dart new file mode 100644 index 0000000..55a9662 --- /dev/null +++ b/test/widgets/connection_dialog_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/widgets/connection_dialog.dart'; +import 'package:provider/provider.dart'; + +class _FakeConnectionProvider extends ConnectionProvider { + int startScanCalls = 0; + int stopScanCalls = 0; + bool _isScanning = false; + + @override + bool get isScanning => _isScanning; + + @override + List get scannedDevices => const []; + + @override + String? get error => null; + + @override + Future startScan() async { + startScanCalls += 1; + _isScanning = true; + notifyListeners(); + } + + @override + Future stopScan() async { + stopScanCalls += 1; + _isScanning = false; + notifyListeners(); + } +} + +void main() { + testWidgets('BLE scan waits for explicit user action', (tester) async { + final connectionProvider = _FakeConnectionProvider(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: connectionProvider, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: ConnectionDialog()), + ), + ), + ); + + await tester.pump(); + + expect(connectionProvider.startScanCalls, 0); + expect( + find.text('Press scan to search for nearby devices'), + findsOneWidget, + ); + expect(find.text('Scan'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Scan')); + await tester.pump(); + + expect(connectionProvider.stopScanCalls, 1); + expect(connectionProvider.startScanCalls, 1); + }); +}