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:
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user