mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: Add manual TCP connect flow
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
lib/services/recent_tcp_connections_service.dart
Normal file
102
lib/services/recent_tcp_connections_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user