From 14a41b210ecba6502f81cbdcd0f4e037127eca91 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 5 Apr 2026 19:45:16 +0200 Subject: [PATCH] feat: Add manual TCP IP entry --- lib/l10n/app_en.arb | 4 +- lib/l10n/app_localizations_en.dart | 4 +- lib/widgets/connection_dialog.dart | 235 +++++++++++++++++++---- test/widgets/connection_dialog_test.dart | 101 ++++++++++ 4 files changed, 299 insertions(+), 45 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index efd35a5..7d3af39 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2317,7 +2317,7 @@ "@wizardOverviewFeature1": { "description": "Updated onboarding overview feature 1" }, - "wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.", + "wizardOverviewFeature2": "Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.", "@wizardOverviewFeature2": { "description": "Updated onboarding overview feature 2" }, @@ -2458,7 +2458,7 @@ "@wizardMapOpsDescription": { "description": "Updated onboarding map page description" }, - "wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.", + "wizardMapOpsFeature1": "Track your own position, teammate locations, and share live location to a private channel from the channel menu.", "@wizardMapOpsFeature1": { "description": "Updated onboarding map feature 1" }, diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f487014..ead6ed5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1743,7 +1743,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wizardOverviewFeature2 => - 'Share SAR markers, map drawings, voice clips, and images over the mesh.'; + 'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.'; @override String get wizardOverviewFeature3 => @@ -1860,7 +1860,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wizardMapOpsFeature1 => - 'Track your own position, teammate locations, and movement trails on the map.'; + 'Track your own position, teammate locations, and share live location to a private channel from the channel menu.'; @override String get wizardMapOpsFeature2 => diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index e460627..25da022 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -119,7 +121,9 @@ Future showConnectionDialogFlow( /// Connection Dialog with tabs for BLE devices and Network servers class ConnectionDialog extends StatefulWidget { - const ConnectionDialog({super.key}); + final NetworkScannerService? networkScanner; + + const ConnectionDialog({super.key, this.networkScanner}); @override State createState() => _ConnectionDialogState(); @@ -129,7 +133,7 @@ class _ConnectionDialogState extends State with SingleTickerProviderStateMixin { late TabController _tabController; late final ConnectionProvider _connectionProvider; - final NetworkScannerService _networkScanner = NetworkScannerService(); + late final NetworkScannerService _networkScanner; final List _discoveredServers = []; int _scannedCount = 0; int _totalToScan = 0; @@ -162,6 +166,7 @@ class _ConnectionDialogState extends State context, listen: false, ); + _networkScanner = widget.networkScanner ?? NetworkScannerService(); _networkScanner.onServerDiscovered = (server) { if (!mounted) return; @@ -334,6 +339,9 @@ class _ConnectionDialogState extends State required IconData icon, required String message, required VoidCallback onRefresh, + IconData? secondaryActionIcon, + String? secondaryActionTooltip, + VoidCallback? onSecondaryAction, }) { final theme = Theme.of(context); return Container( @@ -355,18 +363,77 @@ class _ConnectionDialogState extends State ), ), ), - IconButton( - icon: Icon( - Icons.refresh_rounded, - color: theme.colorScheme.onPrimaryContainer, - ), - onPressed: onRefresh, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (secondaryActionIcon != null) + IconButton( + tooltip: secondaryActionTooltip, + icon: Icon( + secondaryActionIcon, + color: theme.colorScheme.onPrimaryContainer, + ), + onPressed: onSecondaryAction, + ), + IconButton( + icon: Icon( + Icons.refresh_rounded, + color: theme.colorScheme.onPrimaryContainer, + ), + onPressed: onRefresh, + ), + ], ), ], ), ); } + Future _promptForManualTcpHost() async { + return showDialog( + context: context, + builder: (dialogContext) => _ManualTcpHostDialog( + initialHost: _connectionProvider.tcpHost, + ), + ); + } + + Future _connectManualTcpHost() async { + final host = await _promptForManualTcpHost(); + if (host == null || !mounted) { + return; + } + + final serverKey = '$host:${NetworkScannerService.defaultPort}'; + final connectionProvider = context.read(); + + setState(() { + _connectingToServerKey = serverKey; + }); + + try { + final success = await connectionProvider.connectTcp( + host, + NetworkScannerService.defaultPort, + ); + if (!success) { + throw Exception( + connectionProvider.error ?? + 'Failed to connect to $host:${NetworkScannerService.defaultPort}', + ); + } + _closeOnSuccessfulConnection(); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _connectingToServerKey = null; + }); + _showConnectionError(error); + } + } + Widget _buildErrorBanner(String message) { final theme = Theme.of(context); return Container( @@ -404,42 +471,47 @@ class _ConnectionDialogState extends State required VoidCallback onAction, }) { final theme = Theme.of(context); - return Center( - child: Padding( + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - icon, - size: 36, - color: theme.colorScheme.onSurfaceVariant.withValues( - alpha: 0.8, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 36, + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.8, + ), + ), ), - ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: onAction, + icon: const Icon(Icons.refresh_rounded), + label: Text(actionLabel), + ), + ], ), - const SizedBox(height: 16), - Text( - title, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - FilledButton.tonalIcon( - onPressed: onAction, - icon: const Icon(Icons.refresh_rounded), - label: Text(actionLabel), - ), - ], + ), ), ), ); @@ -620,6 +692,11 @@ class _ConnectionDialogState extends State message: showingCachedResults ? 'Showing cached results. Tap refresh to rescan.' : 'Scanning local network for MeshCore WiFi devices on port 5000', + secondaryActionIcon: Icons.add_rounded, + secondaryActionTooltip: 'Add IP address', + onSecondaryAction: _connectingToServerKey != null + ? null + : _connectManualTcpHost, onRefresh: _startNetworkScan, ), if (_networkScanner.isScanning) @@ -768,6 +845,82 @@ class _ConnectionDialogState extends State } } +class _ManualTcpHostDialog extends StatefulWidget { + final String? initialHost; + + const _ManualTcpHostDialog({this.initialHost}); + + @override + State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState(); +} + +class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> { + late final TextEditingController _controller; + String? _errorText; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialHost); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final host = _controller.text.trim(); + final parsedAddress = InternetAddress.tryParse(host); + if (parsedAddress == null) { + setState(() { + _errorText = 'Enter a valid IP address'; + }); + return; + } + Navigator.of(context).pop(parsedAddress.address); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Connect by IP address'), + content: TextField( + controller: _controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: 'IP address', + hintText: '192.168.1.42', + helperText: 'Uses TCP port 5000', + border: const OutlineInputBorder(), + errorText: _errorText, + ), + onChanged: (_) { + if (_errorText == null) { + return; + } + setState(() { + _errorText = null; + }); + }, + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _submit, + child: const Text('Connect'), + ), + ], + ); + } +} + typedef _TransportCardBuilder = Widget Function({ required IconData icon, diff --git a/test/widgets/connection_dialog_test.dart b/test/widgets/connection_dialog_test.dart index 23f1fcd..b241765 100644 --- a/test/widgets/connection_dialog_test.dart +++ b/test/widgets/connection_dialog_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.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/services/network_scanner_service.dart'; import 'package:meshcore_sar_app/widgets/connection_dialog.dart'; import 'package:provider/provider.dart'; @@ -53,6 +54,51 @@ class _ConnectableFakeConnectionProvider extends ConnectionProvider { } } +class _TcpConnectableFakeConnectionProvider extends ConnectionProvider { + int connectTcpCalls = 0; + String? connectedHost; + int? connectedPort; + + @override + List get scannedDevices => const []; + + @override + String? get error => null; + + @override + Future connectTcp(String host, int port) async { + connectTcpCalls += 1; + connectedHost = host; + connectedPort = port; + return true; + } +} + +class _FakeNetworkScannerService extends NetworkScannerService { + int scanCalls = 0; + + @override + bool get isScanning => false; + + @override + bool get hasCachedResults => false; + + @override + List get cachedServers => const []; + + @override + Future> scan({int? port}) async { + scanCalls += 1; + return const []; + } + + @override + void clearCache() {} + + @override + void stopScan() {} +} + void main() { testWidgets('BLE scan waits for explicit user action', (tester) async { final connectionProvider = _FakeConnectionProvider(); @@ -126,4 +172,59 @@ void main() { expect(connectionProvider.connectCalls, 1); expect(find.byType(ConnectionDialog), findsNothing); }); + + testWidgets('manual TCP connect accepts an IP address from the network tab', ( + tester, + ) async { + final connectionProvider = _TcpConnectableFakeConnectionProvider(); + final networkScanner = _FakeNetworkScannerService(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: connectionProvider, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ConnectionDialog( + networkScanner: networkScanner, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Network')); + await tester.pumpAndSettle(); + + expect(networkScanner.scanCalls, 1); + + await tester.tap(find.byTooltip('Add IP address')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '192.168.1.42'); + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connectionProvider.connectTcpCalls, 1); + expect(connectionProvider.connectedHost, '192.168.1.42'); + expect(connectionProvider.connectedPort, NetworkScannerService.defaultPort); + expect(find.byType(ConnectionDialog), findsNothing); + }); }