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": {
"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"
},

View File

@@ -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 =>

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -119,7 +121,9 @@ Future<bool> 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<ConnectionDialog> createState() => _ConnectionDialogState();
@@ -129,7 +133,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late final ConnectionProvider _connectionProvider;
final NetworkScannerService _networkScanner = NetworkScannerService();
late final NetworkScannerService _networkScanner;
final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0;
int _totalToScan = 0;
@@ -162,6 +166,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
context,
listen: false,
);
_networkScanner = widget.networkScanner ?? NetworkScannerService();
_networkScanner.onServerDiscovered = (server) {
if (!mounted) return;
@@ -334,6 +339,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required IconData icon,
required String message,
required VoidCallback onRefresh,
IconData? secondaryActionIcon,
String? secondaryActionTooltip,
VoidCallback? onSecondaryAction,
}) {
final theme = Theme.of(context);
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(
icon: Icon(
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) {
final theme = Theme.of(context);
return Container(
@@ -404,9 +471,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required VoidCallback onAction,
}) {
final theme = Theme.of(context);
return Center(
child: Padding(
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -442,6 +512,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
],
),
),
),
),
);
}
@@ -620,6 +692,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
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<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 =
Widget Function({
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: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<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() {
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<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);
});
}