feat: Add manual TCP IP entry

This commit is contained in:
Janez T
2026-04-05 19:45:16 +02:00
parent 56a7fa2fc6
commit 14a41b210e
4 changed files with 299 additions and 45 deletions

View File

@@ -2317,7 +2317,7 @@
"@wizardOverviewFeature1": { "@wizardOverviewFeature1": {
"description": "Updated onboarding overview feature 1" "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": { "@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2" "description": "Updated onboarding overview feature 2"
}, },
@@ -2458,7 +2458,7 @@
"@wizardMapOpsDescription": { "@wizardMapOpsDescription": {
"description": "Updated onboarding map page description" "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": { "@wizardMapOpsFeature1": {
"description": "Updated onboarding map feature 1" "description": "Updated onboarding map feature 1"
}, },

View File

@@ -1743,7 +1743,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewFeature2 => 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 @override
String get wizardOverviewFeature3 => String get wizardOverviewFeature3 =>
@@ -1860,7 +1860,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardMapOpsFeature1 => 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 @override
String get wizardMapOpsFeature2 => String get wizardMapOpsFeature2 =>

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -119,7 +121,9 @@ Future<bool> showConnectionDialogFlow(
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key}); final NetworkScannerService? networkScanner;
const ConnectionDialog({super.key, this.networkScanner});
@override @override
State<ConnectionDialog> createState() => _ConnectionDialogState(); State<ConnectionDialog> createState() => _ConnectionDialogState();
@@ -129,7 +133,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late final ConnectionProvider _connectionProvider; late final ConnectionProvider _connectionProvider;
final NetworkScannerService _networkScanner = NetworkScannerService(); late final NetworkScannerService _networkScanner;
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
@@ -162,6 +166,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
context, context,
listen: false, listen: false,
); );
_networkScanner = widget.networkScanner ?? NetworkScannerService();
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
if (!mounted) return; if (!mounted) return;
@@ -334,6 +339,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required IconData icon, required IconData icon,
required String message, required String message,
required VoidCallback onRefresh, required VoidCallback onRefresh,
IconData? secondaryActionIcon,
String? secondaryActionTooltip,
VoidCallback? onSecondaryAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -355,6 +363,18 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
), ),
), ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (secondaryActionIcon != null)
IconButton(
tooltip: secondaryActionTooltip,
icon: Icon(
secondaryActionIcon,
color: theme.colorScheme.onPrimaryContainer,
),
onPressed: onSecondaryAction,
),
IconButton( IconButton(
icon: Icon( icon: Icon(
Icons.refresh_rounded, Icons.refresh_rounded,
@@ -364,9 +384,56 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
], ],
), ),
],
),
); );
} }
Future<String?> _promptForManualTcpHost() async {
return showDialog<String>(
context: context,
builder: (dialogContext) => _ManualTcpHostDialog(
initialHost: _connectionProvider.tcpHost,
),
);
}
Future<void> _connectManualTcpHost() async {
final host = await _promptForManualTcpHost();
if (host == null || !mounted) {
return;
}
final serverKey = '$host:${NetworkScannerService.defaultPort}';
final connectionProvider = context.read<ConnectionProvider>();
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) { Widget _buildErrorBanner(String message) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -404,9 +471,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required VoidCallback onAction, required VoidCallback onAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return LayoutBuilder(
child: Padding( builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -442,6 +512,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
], ],
), ),
), ),
),
),
); );
} }
@@ -620,6 +692,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
message: showingCachedResults message: showingCachedResults
? 'Showing cached results. Tap refresh to rescan.' ? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for MeshCore WiFi devices on port 5000', : '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, onRefresh: _startNetworkScan,
), ),
if (_networkScanner.isScanning) if (_networkScanner.isScanning)
@@ -768,6 +845,82 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
} }
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 = typedef _TransportCardBuilder =
Widget Function({ Widget Function({
required IconData icon, required IconData icon,

View File

@@ -3,6 +3,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.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:meshcore_sar_app/widgets/connection_dialog.dart';
import 'package:provider/provider.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<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<bool> 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<DiscoveredServer> get cachedServers => const [];
@override
Future<List<DiscoveredServer>> scan({int? port}) async {
scanCalls += 1;
return const [];
}
@override
void clearCache() {}
@override
void stopScan() {}
}
void main() { void main() {
testWidgets('BLE scan waits for explicit user action', (tester) async { testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider(); final connectionProvider = _FakeConnectionProvider();
@@ -126,4 +172,59 @@ void main() {
expect(connectionProvider.connectCalls, 1); expect(connectionProvider.connectCalls, 1);
expect(find.byType(ConnectionDialog), findsNothing); 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<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
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);
});
} }