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 {
if (_scanSession == scanSession) {
_isScanning = false; _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,37 +795,79 @@ 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'),
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),
),
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 serverKey = '${server.ipAddress}:${server.port}';
final isConnectingToThisServer = final isConnectingToThisServer =
_connectingToServerKey == serverKey; _connectingToServerKey == serverKey;
final isAnyConnectionInProgress =
_connectingToServerKey != null;
Future<void> connectServer() async { Future<void> connectServer() async {
final connectionProvider = context
.read<ConnectionProvider>();
setState(() {
_connectingToServerKey = serverKey;
});
try { try {
final isAvailable = await _networkScanner.verifyServer( final isAvailable = await _networkScanner.verifyServer(
server, server,
@@ -753,22 +878,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
final success = await connectionProvider.connectTcp( await _connectTcpEndpoint(
server.ipAddress, host: server.ipAddress,
server.port, port: server.port,
name: server.displayName,
serverKey: serverKey,
); );
if (!success) {
throw Exception(
connectionProvider.error ??
'Failed to connect to ${server.ipAddress}:${server.port}',
);
}
_closeOnSuccessfulConnection();
} catch (e) { } catch (e) {
if (!mounted) return;
setState(() {
_connectingToServerKey = null;
});
_showConnectionError(e); _showConnectionError(e);
} }
} }
@@ -776,10 +892,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
return _buildTransportCard( return _buildTransportCard(
icon: Icons.wifi_rounded, icon: Icons.wifi_rounded,
iconColor: Colors.green, iconColor: Colors.green,
title: server.ipAddress, title: server.displayName,
subtitle: isConnectingToThisServer subtitle: isConnectingToThisServer
? 'Connecting...' ? 'Connecting...'
: 'Port ${server.port}${server.responseTime}ms', : '${server.ipAddress}:${server.port}${server.responseTime}ms',
trailing: isConnectingToThisServer trailing: isConnectingToThisServer
? const SizedBox( ? const SizedBox(
width: 24, width: 24,
@@ -799,6 +915,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
}, },
), ),
if (_networkScanner.isScanning && !hasDiscoveredServers)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
),
],
),
), ),
], ],
); );
@@ -847,66 +970,113 @@ 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,
children: [
TextField(
controller: _hostController,
autofocus: true, autofocus: true,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'IP address', labelText: 'IP address',
hintText: '192.168.1.42', hintText: '192.168.1.42',
helperText: 'Uses TCP port 5000',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _errorText, errorText: _hostErrorText,
), ),
onChanged: (_) { onChanged: (_) {
if (_errorText == null) { if (_hostErrorText == null) {
return; return;
} }
setState(() { setState(() {
_errorText = null; _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(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),

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,
builder: (sheetContext) => _SensorHistorySheet(
publicKeyHex: publicKeyHex, publicKeyHex: publicKeyHex,
initialFieldKey: initialFieldKey, 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,26 +37,21 @@ 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>(
return SafeArea(
child: SizedBox(
height: height,
child: Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
builder: builder:
( (
context, context,
@@ -82,12 +79,8 @@ class _SensorHistorySheetState extends State<_SensorHistorySheet> {
for (final sample in history) ...sample.values.keys, for (final sample in history) ...sample.values.keys,
}.toList() }.toList()
..sort((a, b) { ..sort((a, b) {
final aIndex = options.indexWhere( final aIndex = options.indexWhere((option) => option.key == a);
(option) => option.key == a, final bIndex = options.indexWhere((option) => option.key == b);
);
final bIndex = options.indexWhere(
(option) => option.key == b,
);
if (aIndex == -1 && bIndex == -1) { if (aIndex == -1 && bIndex == -1) {
return a.compareTo(b); return a.compareTo(b);
} }
@@ -100,12 +93,14 @@ class _SensorHistorySheetState extends State<_SensorHistorySheet> {
return aIndex.compareTo(bIndex); return aIndex.compareTo(bIndex);
}); });
_selectedFieldKey = resolveInitialSensorHistoryField( final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: _selectedFieldKey, requestedFieldKey: widget.initialFieldKey,
availableFieldKeys: availableFieldKeys, availableFieldKeys: availableFieldKeys,
); );
final selectedOption = selectedFieldKey == null
final selectedFieldKey = _selectedFieldKey; ? null
: optionByKey[selectedFieldKey];
final selectedCardData = selectedOption?.previewCardData;
final selectedSamples = selectedFieldKey == null final selectedSamples = selectedFieldKey == null
? const <SensorHistorySample>[] ? const <SensorHistorySample>[]
: history : history
@@ -114,151 +109,93 @@ class _SensorHistorySheetState extends State<_SensorHistorySheet> {
sample.values.containsKey(selectedFieldKey), sample.values.containsKey(selectedFieldKey),
) )
.toList(growable: false); .toList(growable: false);
final selectedOption = selectedFieldKey == null final rangeSamples = filterSensorHistorySamples(
? null samples: selectedSamples,
: optionByKey[selectedFieldKey]; range: _selectedRange,
final selectedCardData = selectedOption?.previewCardData; );
final title =
selectedCardData?.label ??
selectedOption?.defaultLabel ??
'Sensor history';
return Padding( return DefaultTabController(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), length: 2,
child: Column( child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text(title),
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sensor history',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text( Text(
contact?.displayName ?? 'Unavailable node', contact?.displayName ?? 'Unavailable node',
style: Theme.of( style: Theme.of(context).textTheme.bodySmall,
context,
).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
), ),
], ],
), ),
), bottom: const TabBar(
IconButton( tabs: [
onPressed: () => Navigator.of(context).pop(), Tab(text: 'Graph'),
icon: const Icon(Icons.close), Tab(text: 'Values'),
),
], ],
), ),
const SizedBox(height: 12), ),
if (availableFieldKeys.isEmpty) body: selectedFieldKey == null
Expanded( ? _SensorHistoryEmptyState(
child: Center( message:
child: Text(
'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.', '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 ...[ : Column(
Wrap( crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: availableFieldKeys children: SensorHistoryRange.values
.map((fieldKey) { .map(
final option = optionByKey[fieldKey]; (range) => ChoiceChip(
return ChoiceChip(
label: Text( label: Text(
option?.defaultLabel ?? fieldKey, sensorHistoryRangeLabel(range),
), ),
selected: fieldKey == selectedFieldKey, selected: _selectedRange == range,
onSelected: (selected) { onSelected: (selected) {
if (!selected) { if (!selected) {
return; return;
} }
setState(() { setState(() {
_selectedFieldKey = fieldKey; _selectedRange = range;
}); });
}, },
); ),
}) )
.toList(growable: false), .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( Expanded(
child: _SensorHistoryLogList( child: TabBarView(
history: history.reversed.toList(growable: false), children: [
selectedFieldKey: selectedFieldKey, _SensorHistoryGraphTab(
optionByKey: optionByKey, samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
totalCount: selectedSamples.length,
range: _selectedRange,
),
_SensorHistoryValuesTab(
samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
range: _selectedRange,
),
],
), ),
), ),
], ],
], ),
), ),
); );
}, },
),
),
); );
} }
} }
@@ -277,42 +214,80 @@ 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)}.',
);
}
final latestValue = samples.last.values[fieldKey]!;
final minValue = samples
.map((sample) => sample.values[fieldKey]!) .map((sample) => sample.values[fieldKey]!)
.reduce(math.min); .reduce(math.min);
final maxValue = samples.isEmpty final maxValue = samples
? null
: samples
.map((sample) => sample.values[fieldKey]!) .map((sample) => sample.values[fieldKey]!)
.reduce(math.max); .reduce(math.max);
final accent = cardData?.accent ?? Theme.of(context).colorScheme.primary;
final theme = Theme.of(context); return ListView(
final accent = cardData?.accent ?? theme.colorScheme.primary; padding: const EdgeInsets.all(16),
children: [
return Row( Row(
children: [ children: [
Expanded( Expanded(
child: _SensorHistoryStatTile( child: _SensorHistoryStatTile(
label: 'Total', label: 'Visible',
value: historyCount.toString(), value: samples.length.toString(),
accent: accent, accent: accent,
), ),
), ),
@@ -320,9 +295,7 @@ class _SensorHistorySummaryCard extends StatelessWidget {
Expanded( Expanded(
child: _SensorHistoryStatTile( child: _SensorHistoryStatTile(
label: 'Latest', label: 'Latest',
value: latestValue == null value: _formatHistoryValue(cardData, latestValue),
? '--'
: _formatHistoryValue(cardData, latestValue),
accent: accent, accent: accent,
), ),
), ),
@@ -330,7 +303,7 @@ class _SensorHistorySummaryCard extends StatelessWidget {
Expanded( Expanded(
child: _SensorHistoryStatTile( child: _SensorHistoryStatTile(
label: 'Min', label: 'Min',
value: minValue == null ? '--' : _formatHistoryValue(cardData, minValue), value: _formatHistoryValue(cardData, minValue),
accent: accent, accent: accent,
), ),
), ),
@@ -338,11 +311,132 @@ class _SensorHistorySummaryCard extends StatelessWidget {
Expanded( Expanded(
child: _SensorHistoryStatTile( child: _SensorHistoryStatTile(
label: 'Max', label: 'Max',
value: maxValue == null ? '--' : _formatHistoryValue(cardData, maxValue), value: _formatHistoryValue(cardData, maxValue),
accent: accent, 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(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),
),
),
child: SizedBox(
height: 280,
child: LineChart(
_historyLineChartData(
context,
samples: samples,
fieldKey: fieldKey,
color: accent,
),
duration: Duration.zero,
),
),
),
],
);
}
}
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,
),
),
); );
} }
} }
@@ -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));
});
} }