fix: Add manual TCP connect flow

This commit is contained in:
Janez T
2026-04-18 08:59:36 +02:00
parent b333b3737e
commit f9dfdc2340
10 changed files with 1001 additions and 521 deletions

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 137; CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 137; CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 137; CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 137; CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 137; CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 137; CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>137</string> <string>140</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000194"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000271">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.331157"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.342101">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="116.573845"> <testcase classname="fastlane.lanes" name="2: build_app" time="145.467717">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="420.572527"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="188.692143">
</testcase> </testcase>

View File

@@ -5,18 +5,23 @@ import 'package:nsd/nsd.dart';
/// Discovered MeshCore device on the network (TCP/WiFi) /// Discovered MeshCore device on the network (TCP/WiFi)
class DiscoveredServer { class DiscoveredServer {
final String name;
final String ipAddress; final String ipAddress;
final int port; final int port;
final int responseTime; // milliseconds final int responseTime; // milliseconds
const DiscoveredServer({ const DiscoveredServer({
required this.name,
required this.ipAddress, required this.ipAddress,
required this.port, required this.port,
required this.responseTime, required this.responseTime,
}); });
String get displayName => name.trim().isNotEmpty ? name.trim() : ipAddress;
@override @override
String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)'; String toString() =>
'DiscoveredServer($displayName @ $ipAddress:$port, ${responseTime}ms)';
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -46,6 +51,7 @@ class NetworkScannerService {
bool _isScanning = false; bool _isScanning = false;
bool get isScanning => _isScanning; bool get isScanning => _isScanning;
int _scanSession = 0;
List<DiscoveredServer> _cachedServers = []; List<DiscoveredServer> _cachedServers = [];
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers); List<DiscoveredServer> 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. /// Try a raw TCP connect to check if the MeshCore TCP server is listening.
Future<DiscoveredServer?> _checkDevice(String ip, int port) async { Future<DiscoveredServer?> _checkDevice(
String ip,
int port, {
String? name,
}) async {
final sw = Stopwatch()..start(); final sw = Stopwatch()..start();
Socket? socket; Socket? socket;
try { try {
@@ -101,6 +111,7 @@ class NetworkScannerService {
debugPrint( debugPrint(
'✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)'); '✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
return DiscoveredServer( return DiscoveredServer(
name: (name ?? ip).trim(),
ipAddress: ip, ipAddress: ip,
port: port, port: port,
responseTime: sw.elapsedMilliseconds, responseTime: sw.elapsedMilliseconds,
@@ -117,7 +128,10 @@ class NetworkScannerService {
// ── mDNS discovery ───────────────────────────────────────────────────────── // ── mDNS discovery ─────────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async { Future<List<DiscoveredServer>> _discoverViaMdns({
int? port,
required int scanSession,
}) async {
final scanPort = port ?? defaultPort; final scanPort = port ?? defaultPort;
final found = <DiscoveredServer>[]; final found = <DiscoveredServer>[];
@@ -131,12 +145,25 @@ class NetworkScannerService {
); );
await Future.delayed(bonjourTimeout); await Future.delayed(bonjourTimeout);
if (!_isScanSessionActive(scanSession)) {
return found;
}
for (final service in _activeDiscovery?.services ?? []) { for (final service in _activeDiscovery?.services ?? []) {
if (!_isScanSessionActive(scanSession)) {
break;
}
for (final addr in service.addresses ?? []) { for (final addr in service.addresses ?? []) {
if (!_isScanSessionActive(scanSession)) {
break;
}
if (localIps.contains(addr.address)) continue; if (localIps.contains(addr.address)) continue;
final result = final result =
await _checkDevice(addr.address, service.port ?? scanPort); await _checkDevice(
addr.address,
service.port ?? scanPort,
name: service.name,
);
if (result != null) { if (result != null) {
found.add(result); found.add(result);
onServerDiscovered?.call(result); onServerDiscovered?.call(result);
@@ -163,7 +190,10 @@ class NetworkScannerService {
// ── Port scan fallback ───────────────────────────────────────────────────── // ── Port scan fallback ─────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _scanByPort({int? port}) async { Future<List<DiscoveredServer>> _scanByPort({
int? port,
required int scanSession,
}) async {
final scanPort = port ?? defaultPort; final scanPort = port ?? defaultPort;
final found = <DiscoveredServer>[]; final found = <DiscoveredServer>[];
@@ -175,10 +205,12 @@ class NetworkScannerService {
'🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort'); '🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
int scanned = 0; 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 batch = ips.skip(i).take(parallelScans).toList();
final results = 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) { for (final result in results) {
if (result != null && !localIps.contains(result.ipAddress)) { if (result != null && !localIps.contains(result.ipAddress)) {
@@ -200,28 +232,51 @@ class NetworkScannerService {
Future<List<DiscoveredServer>> scan({int? port}) async { Future<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) return []; if (_isScanning) return [];
_isScanning = true; _isScanning = true;
final scanSession = ++_scanSession;
try { try {
var found = await _discoverViaMdns(port: port); var found = await _discoverViaMdns(port: port, scanSession: scanSession);
if (found.isEmpty) { if (_isScanSessionActive(scanSession) && found.isEmpty) {
debugPrint( debugPrint(
'🔍 [NetworkScanner] mDNS found nothing, falling back to port scan'); '🔍 [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; return found;
} finally { } finally {
_isScanning = false; if (_scanSession == scanSession) {
_isScanning = false;
}
} }
} }
/// Verify a previously discovered device is still reachable. /// Verify a previously discovered device is still reachable.
Future<bool> verifyServer(DiscoveredServer server) async { Future<bool> 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; return result != null;
} }
void clearCache() => _cachedServers = []; 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');
}),
);
}
}
} }

View File

@@ -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<String, Object> toJson() => <String, Object>{
'name': name,
'host': host,
'port': port,
'lastUsedAt': lastUsedAt.toIso8601String(),
};
factory RecentTcpConnection.fromJson(Map<String, dynamic> 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<List<RecentTcpConnection>> load() async {
final prefs = await SharedPreferences.getInstance();
final storedEntries = prefs.getStringList(_prefsKey) ?? const <String>[];
final connections = <RecentTcpConnection>[];
for (final entry in storedEntries) {
try {
final decoded = jsonDecode(entry);
if (decoded is! Map<String, dynamic>) {
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<List<RecentTcpConnection>> 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>[
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;
}
}

View File

@@ -10,10 +10,18 @@ import '../providers/contacts_provider.dart';
import '../screens/discovery_screen.dart'; import '../screens/discovery_screen.dart';
import '../services/network_scanner_service.dart'; import '../services/network_scanner_service.dart';
import '../services/profile_workspace_coordinator.dart'; import '../services/profile_workspace_coordinator.dart';
import '../services/recent_tcp_connections_service.dart';
import '../services/serial/serial_transport.dart'; import '../services/serial/serial_transport.dart';
enum _ConnectionDialogResult { connected } enum _ConnectionDialogResult { connected }
class _ManualTcpEndpoint {
final String host;
final int port;
const _ManualTcpEndpoint({required this.host, required this.port});
}
Future<void> _initializeConnectedWorkspace({ Future<void> _initializeConnectedWorkspace({
required ProfileWorkspaceCoordinator profileWorkspaceCoordinator, required ProfileWorkspaceCoordinator profileWorkspaceCoordinator,
required AppProvider appProvider, required AppProvider appProvider,
@@ -135,6 +143,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
late final ConnectionProvider _connectionProvider; late final ConnectionProvider _connectionProvider;
late final NetworkScannerService _networkScanner; late final NetworkScannerService _networkScanner;
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
List<RecentTcpConnection> _recentServers = const <RecentTcpConnection>[];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
int _lastTabIndex = 0; int _lastTabIndex = 0;
@@ -158,6 +167,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
} }
Future<void> _loadRecentServers() async {
final recentServers = await RecentTcpConnectionsService.load();
if (!mounted) {
return;
}
setState(() {
_recentServers = recentServers;
});
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -186,6 +205,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
}; };
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
_loadRecentServers();
} }
@override @override
@@ -198,6 +218,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
void _startNetworkScan() { void _startNetworkScan() {
_networkScanner.stopScan();
setState(() { setState(() {
_discoveredServers.clear(); _discoveredServers.clear();
_scannedCount = 0; _scannedCount = 0;
@@ -236,6 +257,56 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_showConnectionErrorSnackBar(context, error); _showConnectionErrorSnackBar(context, error);
} }
Future<void> _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<void> _connectTcpEndpoint({
required String host,
required int port,
required String name,
required String serverKey,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
@@ -389,49 +460,35 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
Future<String?> _promptForManualTcpHost() async { Future<_ManualTcpEndpoint?> _promptForManualTcpHost() async {
return showDialog<String>( return showDialog<_ManualTcpEndpoint>(
context: context, context: context,
builder: (dialogContext) => _ManualTcpHostDialog( builder: (dialogContext) => _ManualTcpHostDialog(
initialHost: _connectionProvider.tcpHost, initialHost: _connectionProvider.tcpHost,
initialPort: NetworkScannerService.defaultPort,
), ),
); );
} }
Future<void> _connectManualTcpHost() async { Future<void> _connectManualTcpHost() async {
final host = await _promptForManualTcpHost(); if (_networkScanner.isScanning) {
if (host == null || !mounted) { _networkScanner.stopScan();
if (mounted) {
setState(() {});
}
}
final endpoint = await _promptForManualTcpHost();
if (endpoint == null || !mounted) {
return; return;
} }
final serverKey = '$host:${NetworkScannerService.defaultPort}'; await _connectTcpEndpoint(
final connectionProvider = context.read<ConnectionProvider>(); host: endpoint.host,
port: endpoint.port,
setState(() { name: endpoint.host,
_connectingToServerKey = serverKey; serverKey: '${endpoint.host}:${endpoint.port}',
}); );
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) {
@@ -582,6 +639,23 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
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) { Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -682,6 +756,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
!_networkScanner.isScanning && !_networkScanner.isScanning &&
_networkScanner.hasCachedResults && _networkScanner.hasCachedResults &&
_discoveredServers.isNotEmpty; _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( return Column(
children: [ children: [
@@ -693,8 +774,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
? '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, secondaryActionIcon: Icons.add_rounded,
secondaryActionTooltip: 'Add IP address', secondaryActionTooltip: _networkScanner.isScanning
onSecondaryAction: _connectingToServerKey != null ? 'Cancel scan and add server'
: 'Add server',
onSecondaryAction: isAnyConnectionInProgress
? null ? null
: _connectManualTcpHost, : _connectManualTcpHost,
onRefresh: _startNetworkScan, onRefresh: _startNetworkScan,
@@ -712,92 +795,132 @@ class _ConnectionDialogState extends State<ConnectionDialog>
'Scanning... $_scannedCount/${_totalToScan > 0 ? _totalToScan : "?"} IPs', 'Scanning... $_scannedCount/${_totalToScan > 0 ? _totalToScan : "?"} IPs',
style: Theme.of(context).textTheme.bodySmall, 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( Expanded(
child: _networkScanner.isScanning && _discoveredServers.isEmpty child: showEmptyState
? const Center(child: CircularProgressIndicator())
: _discoveredServers.isEmpty
? _buildEmptyState( ? _buildEmptyState(
icon: Icons.wifi_off_rounded, icon: Icons.wifi_off_rounded,
title: AppLocalizations.of(context)!.noServersFound, title: 'No recent or discovered servers yet',
actionLabel: 'Scan Again', actionLabel: 'Scan Again',
onAction: _startNetworkScan, onAction: _startNetworkScan,
) )
: ListView.builder( : ListView(
itemCount: _discoveredServers.length, children: [
itemBuilder: (context, index) { if (hasRecentServers)
final server = _discoveredServers[index]; _buildNetworkSectionHeader('Recently used'),
final serverKey = '${server.ipAddress}:${server.port}'; for (final server in _recentServers)
final isConnectingToThisServer = _buildTransportCard(
_connectingToServerKey == serverKey; icon: Icons.history_rounded,
final isAnyConnectionInProgress = iconColor: Theme.of(context).colorScheme.primary,
_connectingToServerKey != null; title: server.name,
subtitle:
Future<void> connectServer() async { _connectingToServerKey == '${server.host}:${server.port}'
final connectionProvider = context ? 'Connecting...'
.read<ConnectionProvider>(); : '${server.host}:${server.port}',
trailing:
setState(() { _connectingToServerKey == '${server.host}:${server.port}'
_connectingToServerKey = serverKey; ? const SizedBox(
}); width: 24,
height: 24,
try { child: CircularProgressIndicator(
final isAvailable = await _networkScanner.verifyServer( strokeWidth: 2.5,
server, ),
); )
if (!isAvailable) { : FilledButton.tonal(
throw Exception( onPressed: isAnyConnectionInProgress
'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.', ? null
); : () => _connectTcpEndpoint(
} host: server.host,
port: server.port,
final success = await connectionProvider.connectTcp( name: server.name,
server.ipAddress, serverKey: '${server.host}:${server.port}',
server.port, ),
); child: Text(AppLocalizations.of(context)!.connect),
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,
), ),
) enabled: !isAnyConnectionInProgress,
: FilledButton.tonal( onTap: isAnyConnectionInProgress
onPressed: isAnyConnectionInProgress ? null
? null : () => _connectTcpEndpoint(
: connectServer, host: server.host,
child: Text(AppLocalizations.of(context)!.connect), port: server.port,
), name: server.name,
enabled: !isAnyConnectionInProgress, serverKey: '${server.host}:${server.port}',
onTap: isAnyConnectionInProgress ? null : connectServer, ),
); ),
}, 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<void> 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<ConnectionDialog>
class _ManualTcpHostDialog extends StatefulWidget { class _ManualTcpHostDialog extends StatefulWidget {
final String? initialHost; final String? initialHost;
final int initialPort;
const _ManualTcpHostDialog({this.initialHost}); const _ManualTcpHostDialog({
this.initialHost,
required this.initialPort,
});
@override @override
State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState(); State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState();
} }
class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> { class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
late final TextEditingController _controller; late final TextEditingController _hostController;
String? _errorText; late final TextEditingController _portController;
String? _hostErrorText;
String? _portErrorText;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: widget.initialHost); _hostController = TextEditingController(text: widget.initialHost);
_portController = TextEditingController(text: widget.initialPort.toString());
} }
@override @override
void dispose() { void dispose() {
_controller.dispose(); _hostController.dispose();
_portController.dispose();
super.dispose(); super.dispose();
} }
void _submit() { void _submit() {
final host = _controller.text.trim(); final host = _hostController.text.trim();
final portText = _portController.text.trim();
final parsedAddress = InternetAddress.tryParse(host); final parsedAddress = InternetAddress.tryParse(host);
final parsedPort = int.tryParse(portText);
String? hostErrorText;
String? portErrorText;
if (parsedAddress == null) { 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(() { setState(() {
_errorText = 'Enter a valid IP address'; _hostErrorText = hostErrorText;
_portErrorText = portErrorText;
}); });
return; return;
} }
Navigator.of(context).pop(parsedAddress.address); Navigator.of(
context,
).pop(_ManualTcpEndpoint(host: parsedAddress!.address, port: parsedPort!));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(AppLocalizations.of(context)!.connectByIpAddress), title: Text(AppLocalizations.of(context)!.connectByIpAddress),
content: TextField( content: Column(
controller: _controller, mainAxisSize: MainAxisSize.min,
autofocus: true, children: [
keyboardType: TextInputType.url, TextField(
decoration: InputDecoration( controller: _hostController,
labelText: 'IP address', autofocus: true,
hintText: '192.168.1.42', keyboardType: TextInputType.url,
helperText: 'Uses TCP port 5000', decoration: InputDecoration(
border: const OutlineInputBorder(), labelText: 'IP address',
errorText: _errorText, hintText: '192.168.1.42',
), border: const OutlineInputBorder(),
onChanged: (_) { errorText: _hostErrorText,
if (_errorText == null) { ),
return; onChanged: (_) {
} if (_hostErrorText == null) {
setState(() { return;
_errorText = null; }
}); setState(() {
}, _hostErrorText = null;
onSubmitted: (_) => _submit(), });
},
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: [ actions: [
TextButton( TextButton(

View File

@@ -9,24 +9,26 @@ import '../../providers/contacts_provider.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import 'sensor_telemetry_card.dart'; import 'sensor_telemetry_card.dart';
enum SensorHistoryRange { day, week, month, all }
Future<void> showSensorHistorySheet( Future<void> showSensorHistorySheet(
BuildContext context, { BuildContext context, {
required String publicKeyHex, required String publicKeyHex,
String? initialFieldKey, String? initialFieldKey,
}) { }) {
return showModalBottomSheet<void>( return Navigator.of(context).push(
context: context, MaterialPageRoute<void>(
isScrollControlled: true, builder: (pageContext) => SensorHistoryScreen(
showDragHandle: true, publicKeyHex: publicKeyHex,
builder: (sheetContext) => _SensorHistorySheet( initialFieldKey: initialFieldKey,
publicKeyHex: publicKeyHex, ),
initialFieldKey: initialFieldKey,
), ),
); );
} }
class _SensorHistorySheet extends StatefulWidget { class SensorHistoryScreen extends StatefulWidget {
const _SensorHistorySheet({ const SensorHistoryScreen({
super.key,
required this.publicKeyHex, required this.publicKeyHex,
this.initialFieldKey, this.initialFieldKey,
}); });
@@ -35,230 +37,165 @@ class _SensorHistorySheet extends StatefulWidget {
final String? initialFieldKey; final String? initialFieldKey;
@override @override
State<_SensorHistorySheet> createState() => _SensorHistorySheetState(); State<SensorHistoryScreen> createState() => _SensorHistoryScreenState();
} }
class _SensorHistorySheetState extends State<_SensorHistorySheet> { class _SensorHistoryScreenState extends State<SensorHistoryScreen> {
String? _selectedFieldKey; late SensorHistoryRange _selectedRange;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedFieldKey = widget.initialFieldKey; _selectedRange = SensorHistoryRange.day;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final height = MediaQuery.of(context).size.height * 0.84; return Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
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 = <String, SensorMetricOption>{
for (final option in options) option.key: option,
};
final availableFieldKeys = <String>{
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( final selectedFieldKey = resolveInitialSensorHistoryField(
child: SizedBox( requestedFieldKey: widget.initialFieldKey,
height: height, availableFieldKeys: availableFieldKeys,
child: Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>( );
builder: final selectedOption = selectedFieldKey == null
( ? null
context, : optionByKey[selectedFieldKey];
sensorsProvider, final selectedCardData = selectedOption?.previewCardData;
contactsProvider, final selectedSamples = selectedFieldKey == null
connectionProvider, ? const <SensorHistorySample>[]
child, : history
) { .where(
final contact = sensorsProvider.contactForDisplay( (sample) =>
widget.publicKeyHex, sample.values.containsKey(selectedFieldKey),
contactsProvider: contactsProvider, )
connectionProvider: connectionProvider, .toList(growable: false);
); final rangeSamples = filterSensorHistorySamples(
final history = sensorsProvider.historyFor(widget.publicKeyHex); samples: selectedSamples,
final options = sensorMetricOptionsFor( range: _selectedRange,
contact, );
labelOverrides: sensorsProvider.labelOverridesFor( final title =
widget.publicKeyHex, selectedCardData?.label ??
), selectedOption?.defaultLabel ??
); 'Sensor history';
final optionByKey = <String, SensorMetricOption>{
for (final option in options) option.key: option,
};
final availableFieldKeys = <String>{
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);
});
_selectedFieldKey = resolveInitialSensorHistoryField( return DefaultTabController(
requestedFieldKey: _selectedFieldKey, length: 2,
availableFieldKeys: availableFieldKeys, child: Scaffold(
); appBar: AppBar(
title: Column(
final selectedFieldKey = _selectedFieldKey;
final selectedSamples = selectedFieldKey == null
? const <SensorHistorySample>[]
: 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(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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: [ 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( Expanded(
child: Column( child: TabBarView(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( _SensorHistoryGraphTab(
'Sensor history', samples: rangeSamples,
style: Theme.of( fieldKey: selectedFieldKey,
context, cardData: selectedCardData,
).textTheme.titleLarge?.copyWith( totalCount: selectedSamples.length,
fontWeight: FontWeight.w800, range: _selectedRange,
),
), ),
const SizedBox(height: 4), _SensorHistoryValuesTab(
Text( samples: rangeSamples,
contact?.displayName ?? 'Unavailable node', fieldKey: selectedFieldKey,
style: Theme.of( cardData: selectedCardData,
context, range: _selectedRange,
).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
), ),
], ],
), ),
), ),
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; return availableFieldKeys.first;
} }
class _SensorHistorySummaryCard extends StatelessWidget { List<SensorHistorySample> filterSensorHistorySamples({
const _SensorHistorySummaryCard({ required List<SensorHistorySample> samples,
required this.historyCount, required SensorHistoryRange range,
}) {
if (samples.isEmpty || range == SensorHistoryRange.all) {
return List<SensorHistorySample>.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.samples,
required this.cardData,
required this.fieldKey, required this.fieldKey,
required this.cardData,
required this.totalCount,
required this.range,
}); });
final int historyCount;
final List<SensorHistorySample> samples; final List<SensorHistorySample> samples;
final SensorMetricCardData? cardData;
final String fieldKey; final String fieldKey;
final SensorMetricCardData? cardData;
final int totalCount;
final SensorHistoryRange range;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final latestValue = samples.isEmpty ? null : samples.last.values[fieldKey]; if (samples.isEmpty) {
final minValue = samples.isEmpty return _SensorHistoryEmptyState(
? null message:
: samples 'No samples are available for ${sensorHistoryRangeLabel(range)}.',
.map((sample) => sample.values[fieldKey]!) );
.reduce(math.min); }
final maxValue = samples.isEmpty
? null
: samples
.map((sample) => sample.values[fieldKey]!)
.reduce(math.max);
final theme = Theme.of(context); final latestValue = samples.last.values[fieldKey]!;
final accent = cardData?.accent ?? theme.colorScheme.primary; 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: [ children: [
Expanded( Row(
child: _SensorHistoryStatTile( children: [
label: 'Total', Expanded(
value: historyCount.toString(), child: _SensorHistoryStatTile(
accent: accent, 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), const SizedBox(height: 16),
Expanded( Container(
child: _SensorHistoryStatTile( padding: const EdgeInsets.all(16),
label: 'Latest', decoration: BoxDecoration(
value: latestValue == null color: Theme.of(context).colorScheme.surfaceContainerLow,
? '--' borderRadius: BorderRadius.circular(24),
: _formatHistoryValue(cardData, latestValue), border: Border.all(
accent: accent, color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.35),
),
), ),
), child: SizedBox(
const SizedBox(width: 8), height: 280,
Expanded( child: LineChart(
child: _SensorHistoryStatTile( _historyLineChartData(
label: 'Min', context,
value: minValue == null ? '--' : _formatHistoryValue(cardData, minValue), samples: samples,
accent: accent, fieldKey: fieldKey,
), color: accent,
), ),
const SizedBox(width: 8), duration: Duration.zero,
Expanded( ),
child: _SensorHistoryStatTile(
label: 'Max',
value: maxValue == null ? '--' : _formatHistoryValue(cardData, maxValue),
accent: accent,
), ),
), ),
], ],
@@ -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<SensorHistorySample> 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 { class _SensorHistoryStatTile extends StatelessWidget {
const _SensorHistoryStatTile({ const _SensorHistoryStatTile({
required this.label, 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<SensorHistorySample> history;
final String selectedFieldKey;
final Map<String, SensorMetricOption> 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( LineChartData _historyLineChartData(
BuildContext context, { BuildContext context, {
required List<SensorHistorySample> samples, required List<SensorHistorySample> samples,
@@ -488,10 +502,14 @@ LineChartData _historyLineChartData(
final minValue = values.reduce(math.min); final minValue = values.reduce(math.min);
final maxValue = values.reduce(math.max); final maxValue = values.reduce(math.max);
final spread = maxValue - minValue; 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 minY = minValue - padding;
final maxY = maxValue + 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( return LineChartData(
minX: 0, minX: 0,
@@ -622,7 +640,7 @@ String _formatSampleTimestamp(DateTime timestamp) {
String _formatChartTimestamp(DateTime timestamp) { String _formatChartTimestamp(DateTime timestamp) {
final local = timestamp.toLocal(); final local = timestamp.toLocal();
final hour = local.hour.toString().padLeft(2, '0'); final month = local.month.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0'); final day = local.day.toString().padLeft(2, '0');
return '$hour:$minute'; return '$month/$day';
} }

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.9.2 sdk: ^3.9.2

View File

@@ -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/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';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeConnectionProvider extends ConnectionProvider { class _FakeConnectionProvider extends ConnectionProvider {
int startScanCalls = 0; int startScanCalls = 0;
@@ -75,10 +76,18 @@ class _TcpConnectableFakeConnectionProvider extends ConnectionProvider {
} }
class _FakeNetworkScannerService extends NetworkScannerService { class _FakeNetworkScannerService extends NetworkScannerService {
_FakeNetworkScannerService({
this.keepScanning = false,
bool initiallyScanning = false,
}) : _isScanning = initiallyScanning;
final bool keepScanning;
int scanCalls = 0; int scanCalls = 0;
int stopScanCalls = 0;
bool _isScanning;
@override @override
bool get isScanning => false; bool get isScanning => _isScanning;
@override @override
bool get hasCachedResults => false; bool get hasCachedResults => false;
@@ -89,6 +98,10 @@ class _FakeNetworkScannerService extends NetworkScannerService {
@override @override
Future<List<DiscoveredServer>> scan({int? port}) async { Future<List<DiscoveredServer>> scan({int? port}) async {
scanCalls += 1; scanCalls += 1;
_isScanning = keepScanning;
if (keepScanning) {
onProgressUpdate?.call(1, 10);
}
return const []; return const [];
} }
@@ -96,10 +109,17 @@ class _FakeNetworkScannerService extends NetworkScannerService {
void clearCache() {} void clearCache() {}
@override @override
void stopScan() {} void stopScan() {
stopScanCalls += 1;
_isScanning = false;
}
} }
void main() { void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
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();
@@ -173,9 +193,82 @@ void main() {
expect(find.byType(ConnectionDialog), findsNothing); 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, tester,
) async { ) 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<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.pump();
await tester.pump(const Duration(milliseconds: 100));
final manualEntryButton = find.widgetWithText(
TextButton,
'Cancel and enter manually',
);
final onPressed = tester.widget<TextButton>(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 connectionProvider = _TcpConnectableFakeConnectionProvider();
final networkScanner = _FakeNetworkScannerService(); final networkScanner = _FakeNetworkScannerService();
@@ -213,18 +306,15 @@ void main() {
await tester.tap(find.text('Network')); await tester.tap(find.text('Network'));
await tester.pumpAndSettle(); 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.tap(find.widgetWithText(FilledButton, 'Connect').first);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '192.168.1.42');
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(connectionProvider.connectTcpCalls, 1); expect(connectionProvider.connectTcpCalls, 1);
expect(connectionProvider.connectedHost, '192.168.1.42'); expect(connectionProvider.connectedHost, '10.0.0.5');
expect(connectionProvider.connectedPort, NetworkScannerService.defaultPort); expect(connectionProvider.connectedPort, 5001);
expect(find.byType(ConnectionDialog), findsNothing);
}); });
} }

View File

@@ -1,8 +1,9 @@
import 'package:flutter_test/flutter_test.dart'; 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'; import 'package:meshcore_sar_app/widgets/sensors/sensor_history_sheet.dart';
void main() { void main() {
test('history sheet honors initial field key when available', () { test('history screen honors initial field key when available', () {
final selectedFieldKey = resolveInitialSensorHistoryField( final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'extra:illuminance_2', requestedFieldKey: 'extra:illuminance_2',
availableFieldKeys: const <String>[ availableFieldKeys: const <String>[
@@ -14,7 +15,7 @@ void main() {
expect(selectedFieldKey, 'extra:illuminance_2'); 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( final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'extra:missing', requestedFieldKey: 'extra:missing',
availableFieldKeys: const <String>[ availableFieldKeys: const <String>[
@@ -26,7 +27,7 @@ void main() {
expect(selectedFieldKey, 'temperature'); 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( final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'temperature', requestedFieldKey: 'temperature',
availableFieldKeys: const <String>[], availableFieldKeys: const <String>[],
@@ -34,4 +35,48 @@ void main() {
expect(selectedFieldKey, isNull); expect(selectedFieldKey, isNull);
}); });
test('history range filters to the latest 24 hours', () {
final samples = <SensorHistorySample>[
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>[
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));
});
} }