fix: Add manual TCP connect flow

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

View File

@@ -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<void> _initializeConnectedWorkspace({
required ProfileWorkspaceCoordinator profileWorkspaceCoordinator,
required AppProvider appProvider,
@@ -135,6 +143,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
late final ConnectionProvider _connectionProvider;
late final NetworkScannerService _networkScanner;
final List<DiscoveredServer> _discoveredServers = [];
List<RecentTcpConnection> _recentServers = const <RecentTcpConnection>[];
int _scannedCount = 0;
int _totalToScan = 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
void initState() {
super.initState();
@@ -186,6 +205,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
};
_tabController.addListener(_onTabChanged);
_loadRecentServers();
}
@override
@@ -198,6 +218,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
}
void _startNetworkScan() {
_networkScanner.stopScan();
setState(() {
_discoveredServers.clear();
_scannedCount = 0;
@@ -236,6 +257,56 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_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
Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>();
@@ -389,49 +460,35 @@ class _ConnectionDialogState extends State<ConnectionDialog>
);
}
Future<String?> _promptForManualTcpHost() async {
return showDialog<String>(
Future<_ManualTcpEndpoint?> _promptForManualTcpHost() async {
return showDialog<_ManualTcpEndpoint>(
context: context,
builder: (dialogContext) => _ManualTcpHostDialog(
initialHost: _connectionProvider.tcpHost,
initialPort: NetworkScannerService.defaultPort,
),
);
}
Future<void> _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<ConnectionProvider>();
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<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) {
final l10n = AppLocalizations.of(context)!;
@@ -682,6 +756,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
!_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<ConnectionDialog>
? '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<ConnectionDialog>
'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<void> connectServer() async {
final connectionProvider = context
.read<ConnectionProvider>();
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<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 {
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(

View File

@@ -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<void> showSensorHistorySheet(
BuildContext context, {
required String publicKeyHex,
String? initialFieldKey,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _SensorHistorySheet(
publicKeyHex: publicKeyHex,
initialFieldKey: initialFieldKey,
return Navigator.of(context).push(
MaterialPageRoute<void>(
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<SensorHistoryScreen> createState() => _SensorHistoryScreenState();
}
class _SensorHistorySheetState extends State<_SensorHistorySheet> {
String? _selectedFieldKey;
class _SensorHistoryScreenState extends State<SensorHistoryScreen> {
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<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(
child: SizedBox(
height: height,
child: 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);
});
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: widget.initialFieldKey,
availableFieldKeys: availableFieldKeys,
);
final selectedOption = selectedFieldKey == null
? null
: optionByKey[selectedFieldKey];
final selectedCardData = selectedOption?.previewCardData;
final selectedSamples = selectedFieldKey == null
? const <SensorHistorySample>[]
: 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 <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(
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<SensorHistorySample> filterSensorHistorySamples({
required List<SensorHistorySample> samples,
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.cardData,
required this.fieldKey,
required this.cardData,
required this.totalCount,
required this.range,
});
final int historyCount;
final List<SensorHistorySample> 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<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 {
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<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(
BuildContext context, {
required List<SensorHistorySample> 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';
}