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