From f9dfdc234067f7907824869ac075161c9cc40a89 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 18 Apr 2026 08:59:36 +0200 Subject: [PATCH] fix: Add manual TCP connect flow --- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/Info.plist | 2 +- ios/fastlane/report.xml | 8 +- lib/services/network_scanner_service.dart | 83 ++- .../recent_tcp_connections_service.dart | 102 +++ lib/widgets/connection_dialog.dart | 446 +++++++---- lib/widgets/sensors/sensor_history_sheet.dart | 702 +++++++++--------- pubspec.yaml | 2 +- test/widgets/connection_dialog_test.dart | 114 ++- test/widgets/sensor_history_sheet_test.dart | 51 +- 10 files changed, 1001 insertions(+), 521 deletions(-) create mode 100644 lib/services/recent_tcp_connections_service.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d0fe214..6d3757c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 137; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 137; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 137; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 137; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 137; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 137; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 17c0aa5..06a43f6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 137 + 140 LSRequiresIPhoneOS ITSAppUsesNonExemptEncryption diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index fc05a60..2d4b632 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/services/network_scanner_service.dart b/lib/services/network_scanner_service.dart index 56c95fc..a6bebce 100644 --- a/lib/services/network_scanner_service.dart +++ b/lib/services/network_scanner_service.dart @@ -5,18 +5,23 @@ import 'package:nsd/nsd.dart'; /// Discovered MeshCore device on the network (TCP/WiFi) class DiscoveredServer { + final String name; final String ipAddress; final int port; final int responseTime; // milliseconds const DiscoveredServer({ + required this.name, required this.ipAddress, required this.port, required this.responseTime, }); + String get displayName => name.trim().isNotEmpty ? name.trim() : ipAddress; + @override - String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)'; + String toString() => + 'DiscoveredServer($displayName @ $ipAddress:$port, ${responseTime}ms)'; @override bool operator ==(Object other) => @@ -46,6 +51,7 @@ class NetworkScannerService { bool _isScanning = false; bool get isScanning => _isScanning; + int _scanSession = 0; List _cachedServers = []; List get cachedServers => List.unmodifiable(_cachedServers); @@ -88,7 +94,11 @@ class NetworkScannerService { } /// Try a raw TCP connect to check if the MeshCore TCP server is listening. - Future _checkDevice(String ip, int port) async { + Future _checkDevice( + String ip, + int port, { + String? name, + }) async { final sw = Stopwatch()..start(); Socket? socket; try { @@ -101,6 +111,7 @@ class NetworkScannerService { debugPrint( '✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)'); return DiscoveredServer( + name: (name ?? ip).trim(), ipAddress: ip, port: port, responseTime: sw.elapsedMilliseconds, @@ -117,7 +128,10 @@ class NetworkScannerService { // ── mDNS discovery ───────────────────────────────────────────────────────── - Future> _discoverViaMdns({int? port}) async { + Future> _discoverViaMdns({ + int? port, + required int scanSession, + }) async { final scanPort = port ?? defaultPort; final found = []; @@ -131,12 +145,25 @@ class NetworkScannerService { ); await Future.delayed(bonjourTimeout); + if (!_isScanSessionActive(scanSession)) { + return found; + } for (final service in _activeDiscovery?.services ?? []) { + if (!_isScanSessionActive(scanSession)) { + break; + } for (final addr in service.addresses ?? []) { + if (!_isScanSessionActive(scanSession)) { + break; + } if (localIps.contains(addr.address)) continue; final result = - await _checkDevice(addr.address, service.port ?? scanPort); + await _checkDevice( + addr.address, + service.port ?? scanPort, + name: service.name, + ); if (result != null) { found.add(result); onServerDiscovered?.call(result); @@ -163,7 +190,10 @@ class NetworkScannerService { // ── Port scan fallback ───────────────────────────────────────────────────── - Future> _scanByPort({int? port}) async { + Future> _scanByPort({ + int? port, + required int scanSession, + }) async { final scanPort = port ?? defaultPort; final found = []; @@ -175,10 +205,12 @@ class NetworkScannerService { '🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort'); int scanned = 0; - for (int i = 0; i < ips.length; i += parallelScans) { + for (int i = 0; i < ips.length && _isScanSessionActive(scanSession); i += parallelScans) { final batch = ips.skip(i).take(parallelScans).toList(); final results = - await Future.wait(batch.map((ip) => _checkDevice(ip, scanPort))); + await Future.wait( + batch.map((ip) => _checkDevice(ip, scanPort, name: ip)), + ); for (final result in results) { if (result != null && !localIps.contains(result.ipAddress)) { @@ -200,28 +232,51 @@ class NetworkScannerService { Future> scan({int? port}) async { if (_isScanning) return []; _isScanning = true; + final scanSession = ++_scanSession; try { - var found = await _discoverViaMdns(port: port); - if (found.isEmpty) { + var found = await _discoverViaMdns(port: port, scanSession: scanSession); + if (_isScanSessionActive(scanSession) && found.isEmpty) { debugPrint( '🔍 [NetworkScanner] mDNS found nothing, falling back to port scan'); - found = await _scanByPort(port: port); + found = await _scanByPort(port: port, scanSession: scanSession); + } + if (_isScanSessionActive(scanSession)) { + _cachedServers = found; } - _cachedServers = found; return found; } finally { - _isScanning = false; + if (_scanSession == scanSession) { + _isScanning = false; + } } } /// Verify a previously discovered device is still reachable. Future verifyServer(DiscoveredServer server) async { - final result = await _checkDevice(server.ipAddress, server.port); + final result = await _checkDevice( + server.ipAddress, + server.port, + name: server.name, + ); return result != null; } void clearCache() => _cachedServers = []; - void stopScan() => _isScanning = false; + bool _isScanSessionActive(int session) => _isScanning && _scanSession == session; + + void stopScan() { + _scanSession += 1; + _isScanning = false; + final activeDiscovery = _activeDiscovery; + _activeDiscovery = null; + if (activeDiscovery != null) { + unawaited( + stopDiscovery(activeDiscovery).catchError((Object error) { + debugPrint('⚠️ [NetworkScanner] Failed to stop mDNS discovery: $error'); + }), + ); + } + } } diff --git a/lib/services/recent_tcp_connections_service.dart b/lib/services/recent_tcp_connections_service.dart new file mode 100644 index 0000000..4d296ec --- /dev/null +++ b/lib/services/recent_tcp_connections_service.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class RecentTcpConnection { + final String name; + final String host; + final int port; + final DateTime lastUsedAt; + + const RecentTcpConnection({ + required this.name, + required this.host, + required this.port, + required this.lastUsedAt, + }); + + Map toJson() => { + 'name': name, + 'host': host, + 'port': port, + 'lastUsedAt': lastUsedAt.toIso8601String(), + }; + + factory RecentTcpConnection.fromJson(Map json) { + return RecentTcpConnection( + name: (json['name'] as String?)?.trim().isNotEmpty == true + ? (json['name'] as String).trim() + : ((json['host'] as String?) ?? '').trim(), + host: ((json['host'] as String?) ?? '').trim(), + port: (json['port'] as num?)?.toInt() ?? 0, + lastUsedAt: + DateTime.tryParse((json['lastUsedAt'] as String?) ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + } +} + +class RecentTcpConnectionsService { + static const String _prefsKey = 'recent_tcp_connections_v1'; + static const int _maxEntries = 5; + + static Future> load() async { + final prefs = await SharedPreferences.getInstance(); + final storedEntries = prefs.getStringList(_prefsKey) ?? const []; + + final connections = []; + for (final entry in storedEntries) { + try { + final decoded = jsonDecode(entry); + if (decoded is! Map) { + continue; + } + final connection = RecentTcpConnection.fromJson(decoded); + if (connection.host.isEmpty || connection.port <= 0) { + continue; + } + connections.add(connection); + } catch (_) { + continue; + } + } + + connections.sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt)); + return connections.take(_maxEntries).toList(growable: false); + } + + static Future> remember({ + required String name, + required String host, + required int port, + }) async { + final trimmedHost = host.trim(); + if (trimmedHost.isEmpty || port <= 0) { + return load(); + } + + final normalizedName = name.trim().isNotEmpty ? name.trim() : trimmedHost; + final existing = await load(); + final updated = [ + RecentTcpConnection( + name: normalizedName, + host: trimmedHost, + port: port, + lastUsedAt: DateTime.now(), + ), + ...existing.where( + (connection) => + connection.host != trimmedHost || connection.port != port, + ), + ].take(_maxEntries).toList(growable: false); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _prefsKey, + updated + .map((connection) => jsonEncode(connection.toJson())) + .toList(growable: false), + ); + return updated; + } +} diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index 6993a05..19ffa98 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -10,10 +10,18 @@ import '../providers/contacts_provider.dart'; import '../screens/discovery_screen.dart'; import '../services/network_scanner_service.dart'; import '../services/profile_workspace_coordinator.dart'; +import '../services/recent_tcp_connections_service.dart'; import '../services/serial/serial_transport.dart'; enum _ConnectionDialogResult { connected } +class _ManualTcpEndpoint { + final String host; + final int port; + + const _ManualTcpEndpoint({required this.host, required this.port}); +} + Future _initializeConnectedWorkspace({ required ProfileWorkspaceCoordinator profileWorkspaceCoordinator, required AppProvider appProvider, @@ -135,6 +143,7 @@ class _ConnectionDialogState extends State late final ConnectionProvider _connectionProvider; late final NetworkScannerService _networkScanner; final List _discoveredServers = []; + List _recentServers = const []; int _scannedCount = 0; int _totalToScan = 0; int _lastTabIndex = 0; @@ -158,6 +167,16 @@ class _ConnectionDialogState extends State } } + Future _loadRecentServers() async { + final recentServers = await RecentTcpConnectionsService.load(); + if (!mounted) { + return; + } + setState(() { + _recentServers = recentServers; + }); + } + @override void initState() { super.initState(); @@ -186,6 +205,7 @@ class _ConnectionDialogState extends State }; _tabController.addListener(_onTabChanged); + _loadRecentServers(); } @override @@ -198,6 +218,7 @@ class _ConnectionDialogState extends State } void _startNetworkScan() { + _networkScanner.stopScan(); setState(() { _discoveredServers.clear(); _scannedCount = 0; @@ -236,6 +257,56 @@ class _ConnectionDialogState extends State _showConnectionErrorSnackBar(context, error); } + Future _rememberRecentServer({ + required String name, + required String host, + required int port, + }) async { + final recentServers = await RecentTcpConnectionsService.remember( + name: name, + host: host, + port: port, + ); + if (!mounted) { + return; + } + setState(() { + _recentServers = recentServers; + }); + } + + Future _connectTcpEndpoint({ + required String host, + required int port, + required String name, + required String serverKey, + }) async { + final connectionProvider = context.read(); + + setState(() { + _connectingToServerKey = serverKey; + }); + + try { + final success = await connectionProvider.connectTcp(host, port); + if (!success) { + throw Exception( + connectionProvider.error ?? 'Failed to connect to $host:$port', + ); + } + await _rememberRecentServer(name: name, host: host, port: port); + _closeOnSuccessfulConnection(); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _connectingToServerKey = null; + }); + _showConnectionError(error); + } + } + @override Widget build(BuildContext context) { final connectionProvider = context.watch(); @@ -389,49 +460,35 @@ class _ConnectionDialogState extends State ); } - Future _promptForManualTcpHost() async { - return showDialog( + Future<_ManualTcpEndpoint?> _promptForManualTcpHost() async { + return showDialog<_ManualTcpEndpoint>( context: context, builder: (dialogContext) => _ManualTcpHostDialog( initialHost: _connectionProvider.tcpHost, + initialPort: NetworkScannerService.defaultPort, ), ); } Future _connectManualTcpHost() async { - final host = await _promptForManualTcpHost(); - if (host == null || !mounted) { + if (_networkScanner.isScanning) { + _networkScanner.stopScan(); + if (mounted) { + setState(() {}); + } + } + + final endpoint = await _promptForManualTcpHost(); + if (endpoint == 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); - } + await _connectTcpEndpoint( + host: endpoint.host, + port: endpoint.port, + name: endpoint.host, + serverKey: '${endpoint.host}:${endpoint.port}', + ); } Widget _buildErrorBanner(String message) { @@ -582,6 +639,23 @@ class _ConnectionDialogState extends State ); } + Widget _buildNetworkSectionHeader(String label) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { final l10n = AppLocalizations.of(context)!; @@ -682,6 +756,13 @@ class _ConnectionDialogState extends State !_networkScanner.isScanning && _networkScanner.hasCachedResults && _discoveredServers.isNotEmpty; + final bool hasRecentServers = _recentServers.isNotEmpty; + final bool hasDiscoveredServers = _discoveredServers.isNotEmpty; + final bool showEmptyState = + !_networkScanner.isScanning && + !hasRecentServers && + !hasDiscoveredServers; + final bool isAnyConnectionInProgress = _connectingToServerKey != null; return Column( children: [ @@ -693,8 +774,10 @@ class _ConnectionDialogState extends State ? '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 + secondaryActionTooltip: _networkScanner.isScanning + ? 'Cancel scan and add server' + : 'Add server', + onSecondaryAction: isAnyConnectionInProgress ? null : _connectManualTcpHost, onRefresh: _startNetworkScan, @@ -712,92 +795,132 @@ class _ConnectionDialogState extends State 'Scanning... $_scannedCount/${_totalToScan > 0 ? _totalToScan : "?"} IPs', style: Theme.of(context).textTheme.bodySmall, ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _connectManualTcpHost, + icon: const Icon(Icons.close_rounded), + label: const Text('Cancel and enter manually'), + ), + ), ], ), ), Expanded( - child: _networkScanner.isScanning && _discoveredServers.isEmpty - ? const Center(child: CircularProgressIndicator()) - : _discoveredServers.isEmpty + child: showEmptyState ? _buildEmptyState( icon: Icons.wifi_off_rounded, - title: AppLocalizations.of(context)!.noServersFound, + title: 'No recent or discovered servers yet', actionLabel: 'Scan Again', onAction: _startNetworkScan, ) - : ListView.builder( - itemCount: _discoveredServers.length, - itemBuilder: (context, index) { - final server = _discoveredServers[index]; - final serverKey = '${server.ipAddress}:${server.port}'; - final isConnectingToThisServer = - _connectingToServerKey == serverKey; - final isAnyConnectionInProgress = - _connectingToServerKey != null; - - Future connectServer() async { - final connectionProvider = context - .read(); - - setState(() { - _connectingToServerKey = serverKey; - }); - - try { - final isAvailable = await _networkScanner.verifyServer( - server, - ); - if (!isAvailable) { - throw Exception( - 'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.', - ); - } - - final success = await connectionProvider.connectTcp( - server.ipAddress, - server.port, - ); - if (!success) { - throw Exception( - connectionProvider.error ?? - 'Failed to connect to ${server.ipAddress}:${server.port}', - ); - } - _closeOnSuccessfulConnection(); - } catch (e) { - if (!mounted) return; - setState(() { - _connectingToServerKey = null; - }); - _showConnectionError(e); - } - } - - return _buildTransportCard( - icon: Icons.wifi_rounded, - iconColor: Colors.green, - title: server.ipAddress, - subtitle: isConnectingToThisServer - ? 'Connecting...' - : 'Port ${server.port} • ${server.responseTime}ms', - trailing: isConnectingToThisServer - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2.5, + : ListView( + children: [ + if (hasRecentServers) + _buildNetworkSectionHeader('Recently used'), + for (final server in _recentServers) + _buildTransportCard( + icon: Icons.history_rounded, + iconColor: Theme.of(context).colorScheme.primary, + title: server.name, + subtitle: + _connectingToServerKey == '${server.host}:${server.port}' + ? 'Connecting...' + : '${server.host}:${server.port}', + trailing: + _connectingToServerKey == '${server.host}:${server.port}' + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ) + : FilledButton.tonal( + onPressed: isAnyConnectionInProgress + ? null + : () => _connectTcpEndpoint( + host: server.host, + port: server.port, + name: server.name, + serverKey: '${server.host}:${server.port}', + ), + child: Text(AppLocalizations.of(context)!.connect), ), - ) - : FilledButton.tonal( - onPressed: isAnyConnectionInProgress - ? null - : connectServer, - child: Text(AppLocalizations.of(context)!.connect), - ), - enabled: !isAnyConnectionInProgress, - onTap: isAnyConnectionInProgress ? null : connectServer, - ); - }, + enabled: !isAnyConnectionInProgress, + onTap: isAnyConnectionInProgress + ? null + : () => _connectTcpEndpoint( + host: server.host, + port: server.port, + name: server.name, + serverKey: '${server.host}:${server.port}', + ), + ), + if (hasDiscoveredServers) + _buildNetworkSectionHeader('Discovered on this network'), + for (final server in _discoveredServers) + Builder( + builder: (context) { + final serverKey = '${server.ipAddress}:${server.port}'; + final isConnectingToThisServer = + _connectingToServerKey == serverKey; + + Future connectServer() async { + try { + final isAvailable = await _networkScanner.verifyServer( + server, + ); + if (!isAvailable) { + throw Exception( + 'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.', + ); + } + + await _connectTcpEndpoint( + host: server.ipAddress, + port: server.port, + name: server.displayName, + serverKey: serverKey, + ); + } catch (e) { + _showConnectionError(e); + } + } + + return _buildTransportCard( + icon: Icons.wifi_rounded, + iconColor: Colors.green, + title: server.displayName, + subtitle: isConnectingToThisServer + ? 'Connecting...' + : '${server.ipAddress}:${server.port} • ${server.responseTime}ms', + trailing: isConnectingToThisServer + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ) + : FilledButton.tonal( + onPressed: isAnyConnectionInProgress + ? null + : connectServer, + child: Text(AppLocalizations.of(context)!.connect), + ), + enabled: !isAnyConnectionInProgress, + onTap: isAnyConnectionInProgress ? null : connectServer, + ); + }, + ), + if (_networkScanner.isScanning && !hasDiscoveredServers) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ), + ], ), ), ], @@ -847,65 +970,112 @@ class _ConnectionDialogState extends State class _ManualTcpHostDialog extends StatefulWidget { final String? initialHost; + final int initialPort; - const _ManualTcpHostDialog({this.initialHost}); + const _ManualTcpHostDialog({ + this.initialHost, + required this.initialPort, + }); @override State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState(); } class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> { - late final TextEditingController _controller; - String? _errorText; + late final TextEditingController _hostController; + late final TextEditingController _portController; + String? _hostErrorText; + String? _portErrorText; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.initialHost); + _hostController = TextEditingController(text: widget.initialHost); + _portController = TextEditingController(text: widget.initialPort.toString()); } @override void dispose() { - _controller.dispose(); + _hostController.dispose(); + _portController.dispose(); super.dispose(); } void _submit() { - final host = _controller.text.trim(); + final host = _hostController.text.trim(); + final portText = _portController.text.trim(); final parsedAddress = InternetAddress.tryParse(host); + final parsedPort = int.tryParse(portText); + String? hostErrorText; + String? portErrorText; + if (parsedAddress == null) { + hostErrorText = 'Enter a valid IP address'; + } + if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) { + portErrorText = 'Enter a valid TCP port'; + } + if (hostErrorText != null || portErrorText != null) { setState(() { - _errorText = 'Enter a valid IP address'; + _hostErrorText = hostErrorText; + _portErrorText = portErrorText; }); return; } - Navigator.of(context).pop(parsedAddress.address); + Navigator.of( + context, + ).pop(_ManualTcpEndpoint(host: parsedAddress!.address, port: parsedPort!)); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(AppLocalizations.of(context)!.connectByIpAddress), - 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(), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _hostController, + autofocus: true, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: 'IP address', + hintText: '192.168.1.42', + border: const OutlineInputBorder(), + errorText: _hostErrorText, + ), + onChanged: (_) { + if (_hostErrorText == null) { + return; + } + setState(() { + _hostErrorText = null; + }); + }, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + TextField( + controller: _portController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'TCP port', + hintText: NetworkScannerService.defaultPort.toString(), + helperText: 'Custom server port', + border: const OutlineInputBorder(), + errorText: _portErrorText, + ), + onChanged: (_) { + if (_portErrorText == null) { + return; + } + setState(() { + _portErrorText = null; + }); + }, + onSubmitted: (_) => _submit(), + ), + ], ), actions: [ TextButton( diff --git a/lib/widgets/sensors/sensor_history_sheet.dart b/lib/widgets/sensors/sensor_history_sheet.dart index 031b4ec..25ae8eb 100644 --- a/lib/widgets/sensors/sensor_history_sheet.dart +++ b/lib/widgets/sensors/sensor_history_sheet.dart @@ -9,24 +9,26 @@ import '../../providers/contacts_provider.dart'; import '../../providers/sensors_provider.dart'; import 'sensor_telemetry_card.dart'; +enum SensorHistoryRange { day, week, month, all } + Future showSensorHistorySheet( BuildContext context, { required String publicKeyHex, String? initialFieldKey, }) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (sheetContext) => _SensorHistorySheet( - publicKeyHex: publicKeyHex, - initialFieldKey: initialFieldKey, + return Navigator.of(context).push( + MaterialPageRoute( + builder: (pageContext) => SensorHistoryScreen( + publicKeyHex: publicKeyHex, + initialFieldKey: initialFieldKey, + ), ), ); } -class _SensorHistorySheet extends StatefulWidget { - const _SensorHistorySheet({ +class SensorHistoryScreen extends StatefulWidget { + const SensorHistoryScreen({ + super.key, required this.publicKeyHex, this.initialFieldKey, }); @@ -35,230 +37,165 @@ class _SensorHistorySheet extends StatefulWidget { final String? initialFieldKey; @override - State<_SensorHistorySheet> createState() => _SensorHistorySheetState(); + State createState() => _SensorHistoryScreenState(); } -class _SensorHistorySheetState extends State<_SensorHistorySheet> { - String? _selectedFieldKey; +class _SensorHistoryScreenState extends State { + late SensorHistoryRange _selectedRange; @override void initState() { super.initState(); - _selectedFieldKey = widget.initialFieldKey; + _selectedRange = SensorHistoryRange.day; } @override Widget build(BuildContext context) { - final height = MediaQuery.of(context).size.height * 0.84; + return Consumer3( + builder: + ( + context, + sensorsProvider, + contactsProvider, + connectionProvider, + child, + ) { + final contact = sensorsProvider.contactForDisplay( + widget.publicKeyHex, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final history = sensorsProvider.historyFor(widget.publicKeyHex); + final options = sensorMetricOptionsFor( + contact, + labelOverrides: sensorsProvider.labelOverridesFor( + widget.publicKeyHex, + ), + ); + final optionByKey = { + for (final option in options) option.key: option, + }; + final availableFieldKeys = { + for (final sample in history) ...sample.values.keys, + }.toList() + ..sort((a, b) { + final aIndex = options.indexWhere((option) => option.key == a); + final bIndex = options.indexWhere((option) => option.key == b); + if (aIndex == -1 && bIndex == -1) { + return a.compareTo(b); + } + if (aIndex == -1) { + return 1; + } + if (bIndex == -1) { + return -1; + } + return aIndex.compareTo(bIndex); + }); - return SafeArea( - child: SizedBox( - height: height, - child: Consumer3( - builder: - ( - context, - sensorsProvider, - contactsProvider, - connectionProvider, - child, - ) { - final contact = sensorsProvider.contactForDisplay( - widget.publicKeyHex, - contactsProvider: contactsProvider, - connectionProvider: connectionProvider, - ); - final history = sensorsProvider.historyFor(widget.publicKeyHex); - final options = sensorMetricOptionsFor( - contact, - labelOverrides: sensorsProvider.labelOverridesFor( - widget.publicKeyHex, - ), - ); - final optionByKey = { - for (final option in options) option.key: option, - }; - final availableFieldKeys = { - for (final sample in history) ...sample.values.keys, - }.toList() - ..sort((a, b) { - final aIndex = options.indexWhere( - (option) => option.key == a, - ); - final bIndex = options.indexWhere( - (option) => option.key == b, - ); - if (aIndex == -1 && bIndex == -1) { - return a.compareTo(b); - } - if (aIndex == -1) { - return 1; - } - if (bIndex == -1) { - return -1; - } - return aIndex.compareTo(bIndex); - }); + final selectedFieldKey = resolveInitialSensorHistoryField( + requestedFieldKey: widget.initialFieldKey, + availableFieldKeys: availableFieldKeys, + ); + final selectedOption = selectedFieldKey == null + ? null + : optionByKey[selectedFieldKey]; + final selectedCardData = selectedOption?.previewCardData; + final selectedSamples = selectedFieldKey == null + ? const [] + : history + .where( + (sample) => + sample.values.containsKey(selectedFieldKey), + ) + .toList(growable: false); + final rangeSamples = filterSensorHistorySamples( + samples: selectedSamples, + range: _selectedRange, + ); + final title = + selectedCardData?.label ?? + selectedOption?.defaultLabel ?? + 'Sensor history'; - _selectedFieldKey = resolveInitialSensorHistoryField( - requestedFieldKey: _selectedFieldKey, - availableFieldKeys: availableFieldKeys, - ); - - final selectedFieldKey = _selectedFieldKey; - final selectedSamples = selectedFieldKey == null - ? const [] - : history - .where( - (sample) => - sample.values.containsKey(selectedFieldKey), - ) - .toList(growable: false); - final selectedOption = selectedFieldKey == null - ? null - : optionByKey[selectedFieldKey]; - final selectedCardData = selectedOption?.previewCardData; - - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + Text(title), + Text( + contact?.displayName ?? 'Unavailable node', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + bottom: const TabBar( + tabs: [ + Tab(text: 'Graph'), + Tab(text: 'Values'), + ], + ), + ), + body: selectedFieldKey == null + ? _SensorHistoryEmptyState( + message: + 'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.', + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: SensorHistoryRange.values + .map( + (range) => ChoiceChip( + label: Text( + sensorHistoryRangeLabel(range), + ), + selected: _selectedRange == range, + onSelected: (selected) { + if (!selected) { + return; + } + setState(() { + _selectedRange = range; + }); + }, + ), + ) + .toList(growable: false), + ), + ), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: TabBarView( children: [ - Text( - 'Sensor history', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w800, - ), + _SensorHistoryGraphTab( + samples: rangeSamples, + fieldKey: selectedFieldKey, + cardData: selectedCardData, + totalCount: selectedSamples.length, + range: _selectedRange, ), - const SizedBox(height: 4), - Text( - contact?.displayName ?? 'Unavailable node', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), + _SensorHistoryValuesTab( + samples: rangeSamples, + fieldKey: selectedFieldKey, + cardData: selectedCardData, + range: _selectedRange, ), ], ), ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), ], ), - const SizedBox(height: 12), - if (availableFieldKeys.isEmpty) - Expanded( - child: Center( - child: Text( - 'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ) - else ...[ - Wrap( - spacing: 8, - runSpacing: 8, - children: availableFieldKeys - .map((fieldKey) { - final option = optionByKey[fieldKey]; - return ChoiceChip( - label: Text( - option?.defaultLabel ?? fieldKey, - ), - selected: fieldKey == selectedFieldKey, - onSelected: (selected) { - if (!selected) { - return; - } - setState(() { - _selectedFieldKey = fieldKey; - }); - }, - ); - }) - .toList(growable: false), - ), - const SizedBox(height: 16), - _SensorHistorySummaryCard( - historyCount: history.length, - samples: selectedSamples, - cardData: selectedCardData, - fieldKey: selectedFieldKey!, - ), - const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outlineVariant.withValues(alpha: 0.35), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - selectedCardData?.label ?? - selectedOption?.defaultLabel ?? - selectedFieldKey, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - SizedBox( - height: 220, - child: LineChart( - _historyLineChartData( - context, - samples: selectedSamples, - fieldKey: selectedFieldKey, - color: - selectedCardData?.accent ?? - Theme.of(context).colorScheme.primary, - ), - duration: Duration.zero, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Expanded( - child: _SensorHistoryLogList( - history: history.reversed.toList(growable: false), - selectedFieldKey: selectedFieldKey, - optionByKey: optionByKey, - ), - ), - ], - ], - ), - ); - }, - ), - ), + ), + ); + }, ); } } @@ -277,69 +214,139 @@ String? resolveInitialSensorHistoryField({ return availableFieldKeys.first; } -class _SensorHistorySummaryCard extends StatelessWidget { - const _SensorHistorySummaryCard({ - required this.historyCount, +List filterSensorHistorySamples({ + required List samples, + required SensorHistoryRange range, +}) { + if (samples.isEmpty || range == SensorHistoryRange.all) { + return List.from(samples); + } + + final latestTimestamp = samples.last.timestamp; + final cutoff = switch (range) { + SensorHistoryRange.day => latestTimestamp.subtract(const Duration(days: 1)), + SensorHistoryRange.week => latestTimestamp.subtract(const Duration(days: 7)), + SensorHistoryRange.month => latestTimestamp.subtract( + const Duration(days: 30), + ), + SensorHistoryRange.all => DateTime.fromMillisecondsSinceEpoch(0), + }; + + return samples + .where((sample) => !sample.timestamp.isBefore(cutoff)) + .toList(growable: false); +} + +String sensorHistoryRangeLabel(SensorHistoryRange range) { + return switch (range) { + SensorHistoryRange.day => '24h', + SensorHistoryRange.week => '7d', + SensorHistoryRange.month => '30d', + SensorHistoryRange.all => 'All', + }; +} + +class _SensorHistoryGraphTab extends StatelessWidget { + const _SensorHistoryGraphTab({ required this.samples, - required this.cardData, required this.fieldKey, + required this.cardData, + required this.totalCount, + required this.range, }); - final int historyCount; final List samples; - final SensorMetricCardData? cardData; final String fieldKey; + final SensorMetricCardData? cardData; + final int totalCount; + final SensorHistoryRange range; @override Widget build(BuildContext context) { - final latestValue = samples.isEmpty ? null : samples.last.values[fieldKey]; - final minValue = samples.isEmpty - ? null - : samples - .map((sample) => sample.values[fieldKey]!) - .reduce(math.min); - final maxValue = samples.isEmpty - ? null - : samples - .map((sample) => sample.values[fieldKey]!) - .reduce(math.max); + if (samples.isEmpty) { + return _SensorHistoryEmptyState( + message: + 'No samples are available for ${sensorHistoryRangeLabel(range)}.', + ); + } - final theme = Theme.of(context); - final accent = cardData?.accent ?? theme.colorScheme.primary; + final latestValue = samples.last.values[fieldKey]!; + final minValue = samples + .map((sample) => sample.values[fieldKey]!) + .reduce(math.min); + final maxValue = samples + .map((sample) => sample.values[fieldKey]!) + .reduce(math.max); + final accent = cardData?.accent ?? Theme.of(context).colorScheme.primary; - return Row( + return ListView( + padding: const EdgeInsets.all(16), children: [ - Expanded( - child: _SensorHistoryStatTile( - label: 'Total', - value: historyCount.toString(), - accent: accent, + Row( + children: [ + Expanded( + child: _SensorHistoryStatTile( + label: 'Visible', + value: samples.length.toString(), + accent: accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SensorHistoryStatTile( + label: 'Latest', + value: _formatHistoryValue(cardData, latestValue), + accent: accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SensorHistoryStatTile( + label: 'Min', + value: _formatHistoryValue(cardData, minValue), + accent: accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SensorHistoryStatTile( + label: 'Max', + value: _formatHistoryValue(cardData, maxValue), + accent: accent, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '$totalCount total readings', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - const SizedBox(width: 8), - Expanded( - child: _SensorHistoryStatTile( - label: 'Latest', - value: latestValue == null - ? '--' - : _formatHistoryValue(cardData, latestValue), - accent: accent, + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.35), + ), ), - ), - const SizedBox(width: 8), - Expanded( - child: _SensorHistoryStatTile( - label: 'Min', - value: minValue == null ? '--' : _formatHistoryValue(cardData, minValue), - accent: accent, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SensorHistoryStatTile( - label: 'Max', - value: maxValue == null ? '--' : _formatHistoryValue(cardData, maxValue), - accent: accent, + child: SizedBox( + height: 280, + child: LineChart( + _historyLineChartData( + context, + samples: samples, + fieldKey: fieldKey, + color: accent, + ), + duration: Duration.zero, + ), ), ), ], @@ -347,6 +354,93 @@ class _SensorHistorySummaryCard extends StatelessWidget { } } +class _SensorHistoryValuesTab extends StatelessWidget { + const _SensorHistoryValuesTab({ + required this.samples, + required this.fieldKey, + required this.cardData, + required this.range, + }); + + final List samples; + final String fieldKey; + final SensorMetricCardData? cardData; + final SensorHistoryRange range; + + @override + Widget build(BuildContext context) { + if (samples.isEmpty) { + return _SensorHistoryEmptyState( + message: + 'No values are available for ${sensorHistoryRangeLabel(range)}.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: samples.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final sample = samples[samples.length - index - 1]; + final value = sample.values[fieldKey]!; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + _formatSampleTimestamp(sample.timestamp), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + _formatHistoryValue(cardData, value), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: + cardData?.accent ?? Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SensorHistoryEmptyState extends StatelessWidget { + const _SensorHistoryEmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ); + } +} + class _SensorHistoryStatTile extends StatelessWidget { const _SensorHistoryStatTile({ required this.label, @@ -390,86 +484,6 @@ class _SensorHistoryStatTile extends StatelessWidget { } } -class _SensorHistoryLogList extends StatelessWidget { - const _SensorHistoryLogList({ - required this.history, - required this.selectedFieldKey, - required this.optionByKey, - }); - - final List history; - final String selectedFieldKey; - final Map optionByKey; - - @override - Widget build(BuildContext context) { - return ListView.separated( - itemCount: history.length, - separatorBuilder: (context, index) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final sample = history[index]; - final selectedValue = sample.values[selectedFieldKey]; - final selectedOption = optionByKey[selectedFieldKey]; - - final secondaryMetrics = sample.values.entries - .where((entry) => entry.key != selectedFieldKey) - .take(3) - .map((entry) { - final option = optionByKey[entry.key]; - final cardData = option?.previewCardData; - return TextSpan( - text: - '${option?.defaultLabel ?? entry.key} ${_formatHistoryValue(cardData, entry.value)} ', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: cardData?.accent ?? Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ); - }) - .toList(growable: false); - - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.35), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _formatSampleTimestamp(sample.timestamp), - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 6), - Text( - '${selectedOption?.defaultLabel ?? selectedFieldKey} ${selectedValue == null ? '--' : _formatHistoryValue(selectedOption?.previewCardData, selectedValue)}', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: - selectedOption?.previewCardData?.accent ?? - Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w700, - ), - ), - if (secondaryMetrics.isNotEmpty) ...[ - const SizedBox(height: 4), - RichText( - text: TextSpan(children: secondaryMetrics), - ), - ], - ], - ), - ); - }, - ); - } -} - LineChartData _historyLineChartData( BuildContext context, { required List samples, @@ -488,10 +502,14 @@ LineChartData _historyLineChartData( final minValue = values.reduce(math.min); final maxValue = values.reduce(math.max); final spread = maxValue - minValue; - final padding = spread == 0 ? math.max(maxValue.abs() * 0.1, 1.0) : spread * 0.15; + final padding = spread == 0 + ? math.max(maxValue.abs() * 0.1, 1.0) + : spread * 0.15; final minY = minValue - padding; final maxY = maxValue + padding; - final interval = spread <= 0 ? math.max(maxValue.abs() / 3, 1.0) : spread / 3; + final interval = spread <= 0 + ? math.max(maxValue.abs() / 3, 1.0) + : spread / 3; return LineChartData( minX: 0, @@ -622,7 +640,7 @@ String _formatSampleTimestamp(DateTime timestamp) { String _formatChartTimestamp(DateTime timestamp) { final local = timestamp.toLocal(); - final hour = local.hour.toString().padLeft(2, '0'); - final minute = local.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + return '$month/$day'; } diff --git a/pubspec.yaml b/pubspec.yaml index 5ca1816..ff51855 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0410.1+52 +version: 2026.0414.1+54 environment: sdk: ^3.9.2 diff --git a/test/widgets/connection_dialog_test.dart b/test/widgets/connection_dialog_test.dart index b241765..9122c64 100644 --- a/test/widgets/connection_dialog_test.dart +++ b/test/widgets/connection_dialog_test.dart @@ -6,6 +6,7 @@ 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'; +import 'package:shared_preferences/shared_preferences.dart'; class _FakeConnectionProvider extends ConnectionProvider { int startScanCalls = 0; @@ -75,10 +76,18 @@ class _TcpConnectableFakeConnectionProvider extends ConnectionProvider { } class _FakeNetworkScannerService extends NetworkScannerService { + _FakeNetworkScannerService({ + this.keepScanning = false, + bool initiallyScanning = false, + }) : _isScanning = initiallyScanning; + + final bool keepScanning; int scanCalls = 0; + int stopScanCalls = 0; + bool _isScanning; @override - bool get isScanning => false; + bool get isScanning => _isScanning; @override bool get hasCachedResults => false; @@ -89,6 +98,10 @@ class _FakeNetworkScannerService extends NetworkScannerService { @override Future> scan({int? port}) async { scanCalls += 1; + _isScanning = keepScanning; + if (keepScanning) { + onProgressUpdate?.call(1, 10); + } return const []; } @@ -96,10 +109,17 @@ class _FakeNetworkScannerService extends NetworkScannerService { void clearCache() {} @override - void stopScan() {} + void stopScan() { + stopScanCalls += 1; + _isScanning = false; + } } void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + testWidgets('BLE scan waits for explicit user action', (tester) async { final connectionProvider = _FakeConnectionProvider(); @@ -173,9 +193,82 @@ void main() { expect(find.byType(ConnectionDialog), findsNothing); }); - testWidgets('manual TCP connect accepts an IP address from the network tab', ( + testWidgets('manual TCP connect can cancel discovery and use a custom port', ( tester, ) async { + tester.view.physicalSize = const Size(1200, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final connectionProvider = _TcpConnectableFakeConnectionProvider(); + final networkScanner = _FakeNetworkScannerService(initiallyScanning: true); + + 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.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final manualEntryButton = find.widgetWithText( + TextButton, + 'Cancel and enter manually', + ); + final onPressed = tester.widget(manualEntryButton).onPressed; + expect(onPressed, isNotNull); + onPressed!(); + await tester.pumpAndSettle(); + + expect(networkScanner.stopScanCalls, greaterThanOrEqualTo(1)); + + await tester.enterText(find.widgetWithText(TextField, 'IP address'), '192.168.1.42'); + await tester.enterText(find.widgetWithText(TextField, 'TCP port'), '6001'); + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connectionProvider.connectTcpCalls, 1); + expect(connectionProvider.connectedHost, '192.168.1.42'); + expect(connectionProvider.connectedPort, 6001); + expect(find.byType(ConnectionDialog), findsNothing); + }); + + testWidgets('recent servers show saved metadata and can reconnect', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'recent_tcp_connections_v1': [ + '{"name":"Mesh Node Alpha","host":"10.0.0.5","port":5001,"lastUsedAt":"2026-04-18T12:00:00.000Z"}', + ], + }); + final connectionProvider = _TcpConnectableFakeConnectionProvider(); final networkScanner = _FakeNetworkScannerService(); @@ -213,18 +306,15 @@ void main() { await tester.tap(find.text('Network')); await tester.pumpAndSettle(); - expect(networkScanner.scanCalls, 1); + expect(find.text('Recently used'), findsOneWidget); + expect(find.text('Mesh Node Alpha'), findsOneWidget); + expect(find.text('10.0.0.5:5001'), findsOneWidget); - 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.tap(find.widgetWithText(FilledButton, 'Connect').first); await tester.pumpAndSettle(); expect(connectionProvider.connectTcpCalls, 1); - expect(connectionProvider.connectedHost, '192.168.1.42'); - expect(connectionProvider.connectedPort, NetworkScannerService.defaultPort); - expect(find.byType(ConnectionDialog), findsNothing); + expect(connectionProvider.connectedHost, '10.0.0.5'); + expect(connectionProvider.connectedPort, 5001); }); } diff --git a/test/widgets/sensor_history_sheet_test.dart b/test/widgets/sensor_history_sheet_test.dart index 7f96627..3715d61 100644 --- a/test/widgets/sensor_history_sheet_test.dart +++ b/test/widgets/sensor_history_sheet_test.dart @@ -1,8 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; import 'package:meshcore_sar_app/widgets/sensors/sensor_history_sheet.dart'; void main() { - test('history sheet honors initial field key when available', () { + test('history screen honors initial field key when available', () { final selectedFieldKey = resolveInitialSensorHistoryField( requestedFieldKey: 'extra:illuminance_2', availableFieldKeys: const [ @@ -14,7 +15,7 @@ void main() { expect(selectedFieldKey, 'extra:illuminance_2'); }); - test('history sheet falls back to first available field', () { + test('history screen falls back to first available field', () { final selectedFieldKey = resolveInitialSensorHistoryField( requestedFieldKey: 'extra:missing', availableFieldKeys: const [ @@ -26,7 +27,7 @@ void main() { expect(selectedFieldKey, 'temperature'); }); - test('history sheet returns null when no fields are available', () { + test('history screen returns null when no fields are available', () { final selectedFieldKey = resolveInitialSensorHistoryField( requestedFieldKey: 'temperature', availableFieldKeys: const [], @@ -34,4 +35,48 @@ void main() { expect(selectedFieldKey, isNull); }); + + test('history range filters to the latest 24 hours', () { + final samples = [ + SensorHistorySample( + timestamp: DateTime(2026, 4, 1, 8), + values: const {'temperature': 10}, + ), + SensorHistorySample( + timestamp: DateTime(2026, 4, 2, 7), + values: const {'temperature': 11}, + ), + SensorHistorySample( + timestamp: DateTime(2026, 4, 2, 8), + values: const {'temperature': 12}, + ), + ]; + + final filtered = filterSensorHistorySamples( + samples: samples, + range: SensorHistoryRange.day, + ); + + expect(filtered.map((sample) => sample.values['temperature']), [10, 11, 12]); + }); + + test('history range all keeps every sample', () { + final samples = [ + SensorHistorySample( + timestamp: DateTime(2026, 4, 1, 8), + values: const {'temperature': 10}, + ), + SensorHistorySample( + timestamp: DateTime(2026, 4, 2, 8), + values: const {'temperature': 12}, + ), + ]; + + final filtered = filterSensorHistorySamples( + samples: samples, + range: SensorHistoryRange.all, + ); + + expect(filtered, hasLength(2)); + }); }