mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
feat: Add manual TCP IP entry
This commit is contained in:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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,18 +363,77 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
Row(
|
||||||
icon: Icon(
|
mainAxisSize: MainAxisSize.min,
|
||||||
Icons.refresh_rounded,
|
children: [
|
||||||
color: theme.colorScheme.onPrimaryContainer,
|
if (secondaryActionIcon != null)
|
||||||
),
|
IconButton(
|
||||||
onPressed: onRefresh,
|
tooltip: secondaryActionTooltip,
|
||||||
|
icon: Icon(
|
||||||
|
secondaryActionIcon,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
onPressed: onSecondaryAction,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.refresh_rounded,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
onPressed: onRefresh,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,42 +471,47 @@ 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: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
children: [
|
child: Center(
|
||||||
Container(
|
child: Column(
|
||||||
width: 80,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
height: 80,
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
width: 80,
|
||||||
shape: BoxShape.circle,
|
height: 80,
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Icon(
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
icon,
|
shape: BoxShape.circle,
|
||||||
size: 36,
|
),
|
||||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
child: Icon(
|
||||||
alpha: 0.8,
|
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<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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user