mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
feat: Open per-metric sensor history
This commit is contained in:
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 136;
|
||||
CURRENT_PROJECT_VERSION = 137;
|
||||
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 = 136;
|
||||
CURRENT_PROJECT_VERSION = 137;
|
||||
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 = 136;
|
||||
CURRENT_PROJECT_VERSION = 137;
|
||||
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 = 136;
|
||||
CURRENT_PROJECT_VERSION = 137;
|
||||
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 = 136;
|
||||
CURRENT_PROJECT_VERSION = 137;
|
||||
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 = 136;
|
||||
CURRENT_PROJECT_VERSION = 137;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>136</string>
|
||||
<string>137</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000194">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408391">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.331157">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="120.175289">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="116.573845">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="1035.867128">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="420.572527">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -12,6 +12,30 @@ import 'contacts_provider.dart';
|
||||
|
||||
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
||||
|
||||
class SensorHistorySample {
|
||||
final DateTime timestamp;
|
||||
final Map<String, double> values;
|
||||
|
||||
const SensorHistorySample({required this.timestamp, required this.values});
|
||||
|
||||
factory SensorHistorySample.fromJson(Map<String, dynamic> json) {
|
||||
final rawValues = json['values'] as Map<String, dynamic>? ?? const {};
|
||||
return SensorHistorySample(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(json['timestampMillis'] as num?)?.toInt() ?? 0,
|
||||
),
|
||||
values: rawValues.map(
|
||||
(key, value) => MapEntry(key, (value as num).toDouble()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'timestampMillis': timestamp.millisecondsSinceEpoch,
|
||||
'values': values,
|
||||
};
|
||||
}
|
||||
|
||||
class SensorsProvider with ChangeNotifier {
|
||||
static const Duration _successStateRetention = Duration(minutes: 1);
|
||||
static const Duration selfAutoRefreshInterval = Duration(seconds: 30);
|
||||
@@ -21,6 +45,10 @@ class SensorsProvider with ChangeNotifier {
|
||||
static const String _metricLabelKey = 'sensor_metric_labels';
|
||||
static const String _metricOrderKey = 'sensor_metric_order';
|
||||
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
|
||||
static const String _historyKey = 'sensor_history_v1';
|
||||
static const String _telemetrySourceChannelPrefix = '__source_channel:';
|
||||
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
|
||||
static const int _maxHistorySamplesPerSensor = 288;
|
||||
static const List<int> supportedAutoRefreshIntervals = <int>[
|
||||
0,
|
||||
5,
|
||||
@@ -62,6 +90,8 @@ class SensorsProvider with ChangeNotifier {
|
||||
<String, List<String>>{};
|
||||
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
|
||||
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
|
||||
final Map<String, List<SensorHistorySample>> _historyBySensor =
|
||||
<String, List<SensorHistorySample>>{};
|
||||
bool _isLoaded = false;
|
||||
bool _isRefreshingAll = false;
|
||||
bool _isRunningAutoRefreshTick = false;
|
||||
@@ -91,6 +121,7 @@ class SensorsProvider with ChangeNotifier {
|
||||
final storedAutoRefreshJson = prefs.getString(
|
||||
_key(_autoRefreshMinutesKey),
|
||||
);
|
||||
final storedHistoryJson = prefs.getString(_key(_historyKey));
|
||||
_watchedSensorKeys
|
||||
..clear()
|
||||
..addAll(stored);
|
||||
@@ -102,6 +133,7 @@ class SensorsProvider with ChangeNotifier {
|
||||
_metricOrderBySensor.clear();
|
||||
_autoRefreshMinutesBySensor.clear();
|
||||
_lastRefreshAttemptAt.clear();
|
||||
_historyBySensor.clear();
|
||||
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||
for (final entry in decoded.entries) {
|
||||
@@ -146,6 +178,21 @@ class SensorsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (storedHistoryJson != null && storedHistoryJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(storedHistoryJson) as Map<String, dynamic>;
|
||||
for (final entry in decoded.entries) {
|
||||
final rawSamples = entry.value as List<dynamic>? ?? const [];
|
||||
final samples = rawSamples
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SensorHistorySample.fromJson)
|
||||
.where((sample) => sample.values.isNotEmpty)
|
||||
.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
if (samples.isNotEmpty) {
|
||||
_historyBySensor[entry.key] = samples;
|
||||
}
|
||||
}
|
||||
}
|
||||
_autoRefreshMinutesBySensor.removeWhere(
|
||||
(key, _) => !_watchedSensorKeys.contains(key),
|
||||
);
|
||||
@@ -247,6 +294,24 @@ class SensorsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistHistory() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final encoded = <String, dynamic>{};
|
||||
for (final entry in _historyBySensor.entries) {
|
||||
if (entry.value.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
encoded[entry.key] = entry.value
|
||||
.map((sample) => sample.toJson())
|
||||
.toList(growable: false);
|
||||
}
|
||||
await prefs.setString(_key(_historyKey), jsonEncode(encoded));
|
||||
} catch (e) {
|
||||
debugPrint('Error saving sensor history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
||||
);
|
||||
@@ -312,6 +377,11 @@ class SensorsProvider with ChangeNotifier {
|
||||
int autoRefreshMinutesFor(String publicKeyHex) =>
|
||||
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
|
||||
|
||||
List<SensorHistorySample> historyFor(String publicKeyHex) =>
|
||||
List<SensorHistorySample>.unmodifiable(
|
||||
_historyBySensor[publicKeyHex] ?? const <SensorHistorySample>[],
|
||||
);
|
||||
|
||||
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
|
||||
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
|
||||
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
|
||||
@@ -577,12 +647,14 @@ class SensorsProvider with ChangeNotifier {
|
||||
_metricOrderBySensor.remove(publicKeyHex);
|
||||
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||
_lastRefreshAttemptAt.remove(publicKeyHex);
|
||||
_historyBySensor.remove(publicKeyHex);
|
||||
await _persistWatchedSensors();
|
||||
await _persistVisibleMetrics();
|
||||
await _persistFieldSpans();
|
||||
await _persistMetricLabels();
|
||||
await _persistMetricOrder();
|
||||
await _persistAutoRefreshMinutes();
|
||||
await _persistHistory();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -795,6 +867,42 @@ class SensorsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> captureTrackedTelemetryHistory({
|
||||
required ContactsProvider contactsProvider,
|
||||
required ConnectionProvider connectionProvider,
|
||||
}) async {
|
||||
final trackedKeys = <String>{
|
||||
..._watchedSensorKeys.where((key) => autoRefreshMinutesFor(key) > 0),
|
||||
};
|
||||
final self = selfContact(contactsProvider, connectionProvider);
|
||||
if (self != null && _lastRefreshAttemptAt.containsKey(self.publicKeyHex)) {
|
||||
trackedKeys.add(self.publicKeyHex);
|
||||
}
|
||||
if (trackedKeys.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
for (final key in trackedKeys) {
|
||||
final contact = contactForDisplay(
|
||||
key,
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
if (contact == null) {
|
||||
continue;
|
||||
}
|
||||
changed = _captureTelemetryHistory(contact) || changed;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _persistHistory();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearExpiredRefreshStates({DateTime? now}) {
|
||||
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
||||
final keysToClear = <String>[];
|
||||
@@ -851,4 +959,100 @@ class SensorsProvider with ChangeNotifier {
|
||||
telemetry: telemetry,
|
||||
);
|
||||
}
|
||||
|
||||
bool _captureTelemetryHistory(Contact contact) {
|
||||
final telemetry = contact.telemetry;
|
||||
if (telemetry == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final values = _historyValuesForTelemetry(telemetry);
|
||||
if (values.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final samples = _historyBySensor.putIfAbsent(
|
||||
contact.publicKeyHex,
|
||||
() => <SensorHistorySample>[],
|
||||
);
|
||||
final timestamp = telemetry.timestamp;
|
||||
final existingIndex = samples.lastIndexWhere(
|
||||
(sample) => sample.timestamp.millisecondsSinceEpoch ==
|
||||
timestamp.millisecondsSinceEpoch,
|
||||
);
|
||||
final nextSample = SensorHistorySample(timestamp: timestamp, values: values);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
final current = samples[existingIndex];
|
||||
if (mapEquals(current.values, values)) {
|
||||
return false;
|
||||
}
|
||||
samples[existingIndex] = nextSample;
|
||||
return true;
|
||||
}
|
||||
|
||||
samples.add(nextSample);
|
||||
samples.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
if (samples.length > _maxHistorySamplesPerSensor) {
|
||||
samples.removeRange(0, samples.length - _maxHistorySamplesPerSensor);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<String, double> _historyValuesForTelemetry(ContactTelemetry telemetry) {
|
||||
final values = <String, double>{};
|
||||
|
||||
if (telemetry.batteryMilliVolts != null) {
|
||||
values['voltage'] = telemetry.batteryMilliVolts! / 1000;
|
||||
}
|
||||
if (telemetry.batteryPercentage != null) {
|
||||
values['battery'] = telemetry.batteryPercentage!;
|
||||
}
|
||||
if (telemetry.temperature != null) {
|
||||
values['temperature'] = telemetry.temperature!;
|
||||
}
|
||||
if (telemetry.humidity != null) {
|
||||
values['humidity'] = telemetry.humidity!;
|
||||
}
|
||||
if (telemetry.pressure != null) {
|
||||
values['pressure'] = telemetry.pressure!;
|
||||
}
|
||||
|
||||
final extraSensorData = telemetry.extraSensorData;
|
||||
if (extraSensorData != null) {
|
||||
for (final entry in extraSensorData.entries) {
|
||||
if (_isTelemetryMetadataKey(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final numericValue = _historyNumericValue(entry.value);
|
||||
if (numericValue == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
values[_extraFieldKey(entry.key)] = numericValue;
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
double? _historyNumericValue(dynamic value) {
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
if (value is bool) {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isTelemetryMetadataKey(String key) {
|
||||
return key.startsWith(_telemetrySourceChannelPrefix) ||
|
||||
key == _rawTelemetryHexKey;
|
||||
}
|
||||
|
||||
String _extraFieldKey(String key) {
|
||||
return 'extra:$key';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes();
|
||||
|
||||
if (widget.autoDiscoverRepeatersOnOpen) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../providers/app_provider.dart';
|
||||
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/sensors_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'messages_tab.dart';
|
||||
import 'contacts_tab.dart';
|
||||
@@ -71,6 +72,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
bool _isSensorsEnabled = false;
|
||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||
String? _lastProfileDeviceKey;
|
||||
Timer? _sensorAutoRefreshTicker;
|
||||
|
||||
List<_HomeTab> get _enabledTabs {
|
||||
return [
|
||||
@@ -116,6 +118,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleConnectionProviderChanged();
|
||||
});
|
||||
_configureSensorAutoRefreshTicker();
|
||||
}
|
||||
|
||||
void _initTabController() {
|
||||
@@ -182,6 +185,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (!_isMapEnabled) {
|
||||
_isMapFullscreen = false;
|
||||
}
|
||||
_configureSensorAutoRefreshTicker();
|
||||
|
||||
final newTabs = _enabledTabs;
|
||||
final newIndex = newTabs.indexOf(oldTab);
|
||||
@@ -252,6 +256,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sensorAutoRefreshTicker?.cancel();
|
||||
_connectionProvider.removeListener(_handleConnectionProviderChanged);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_appProvider.setFastLocationUiActive(false);
|
||||
@@ -269,11 +274,50 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleState = state;
|
||||
_syncFastLocationUiState();
|
||||
_configureSensorAutoRefreshTicker();
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
MeshMapNodesService.syncInBackgroundIfStale();
|
||||
}
|
||||
}
|
||||
|
||||
void _configureSensorAutoRefreshTicker() {
|
||||
_sensorAutoRefreshTicker?.cancel();
|
||||
if (!_isSensorsEnabled || _lifecycleState != AppLifecycleState.resumed) {
|
||||
_sensorAutoRefreshTicker = null;
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_runSensorAutoRefreshTick());
|
||||
_sensorAutoRefreshTicker = Timer.periodic(
|
||||
SensorsProvider.selfAutoRefreshInterval,
|
||||
(_) {
|
||||
unawaited(_runSensorAutoRefreshTick());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runSensorAutoRefreshTick() async {
|
||||
if (!mounted ||
|
||||
!_isSensorsEnabled ||
|
||||
_lifecycleState != AppLifecycleState.resumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sensorsProvider = context.read<SensorsProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
sensorsProvider.clearExpiredRefreshStates();
|
||||
await sensorsProvider.refreshDueSensors(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
await sensorsProvider.captureTrackedTelemetryHistory(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadRxTxPreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import '../services/route_hash_preferences.dart';
|
||||
import '../services/traffic_stats_reporting_service.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
import '../widgets/compact_signal_indicator.dart';
|
||||
import '../widgets/messages/message_trace_sheet.dart';
|
||||
import 'packet_log_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
@@ -769,7 +768,6 @@ class _LiveTrafficCard extends StatelessWidget {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
||||
onLongPress: () => _showTraceSheet(context, entry),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -1034,30 +1032,6 @@ class _LiveTrafficCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _showTraceSheet(
|
||||
BuildContext context,
|
||||
LiveTrafficEntry entry,
|
||||
) {
|
||||
final route = entry.route;
|
||||
if (route == null || route.pathBytes.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => MessageTraceSheet.packetPath(
|
||||
packetPath: route.pathBytes,
|
||||
descriptionOverride:
|
||||
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
|
||||
noRelayMatchTextOverride:
|
||||
'No named nodes could be matched for this packet path.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveTrafficPacketDetails {
|
||||
|
||||
@@ -750,9 +750,7 @@ class _DecodedRouteSection extends StatelessWidget {
|
||||
return FutureBuilder<List<dynamic>>(
|
||||
future: Future.wait<dynamic>([
|
||||
RouteHashPreferences.getHashSize(),
|
||||
MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
),
|
||||
MeshMapNodesService.loadCachedNodes(),
|
||||
]),
|
||||
builder: (context, snapshot) {
|
||||
final decodedRoute = LogRxRouteDecoder.decode(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -10,6 +8,7 @@ import '../providers/map_provider.dart';
|
||||
import '../providers/sensors_provider.dart';
|
||||
import '../widgets/contacts/ping_contact_sheet.dart';
|
||||
import '../widgets/sensors/bthome_met_history_sheet.dart';
|
||||
import '../widgets/sensors/sensor_history_sheet.dart';
|
||||
import '../widgets/sensors/sensor_telemetry_card.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
@@ -23,72 +22,19 @@ class SensorsTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SensorsTabState extends State<SensorsTab> {
|
||||
static const Duration _autoRefreshTickInterval = Duration(seconds: 30);
|
||||
Timer? _minuteTicker;
|
||||
final Map<String, DateTime> _lastCenteredTelemetryAtBySensor =
|
||||
<String, DateTime>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.isActive) {
|
||||
unawaited(_handleMinuteTick());
|
||||
_scheduleMinuteTicker();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_minuteTicker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SensorsTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isActive == widget.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.isActive) {
|
||||
unawaited(_handleMinuteTick());
|
||||
_scheduleMinuteTicker();
|
||||
return;
|
||||
}
|
||||
|
||||
_minuteTicker?.cancel();
|
||||
_minuteTicker = null;
|
||||
}
|
||||
|
||||
void _scheduleMinuteTicker() {
|
||||
_minuteTicker?.cancel();
|
||||
if (!widget.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
_minuteTicker = Timer.periodic(_autoRefreshTickInterval, (_) {
|
||||
unawaited(_handleMinuteTick());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleMinuteTick() async {
|
||||
if (!mounted || !widget.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sensorsProvider = context.read<SensorsProvider>();
|
||||
sensorsProvider.clearExpiredRefreshStates();
|
||||
await sensorsProvider.refreshDueSensors(
|
||||
contactsProvider: context.read<ContactsProvider>(),
|
||||
connectionProvider: context.read<ConnectionProvider>(),
|
||||
now: DateTime.now(),
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _showAddSensorSheet(BuildContext context) async {
|
||||
final sensorsProvider = context.read<SensorsProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
@@ -356,6 +302,20 @@ class _SensorsTabState extends State<SensorsTab> {
|
||||
: null,
|
||||
onCustomize: () =>
|
||||
_showMetricSelector(context, key, contact),
|
||||
onMetricTap: (fieldKey) async {
|
||||
final history = sensorsProvider.historyFor(key);
|
||||
final hasHistoryForField = history.any(
|
||||
(sample) => sample.values.containsKey(fieldKey),
|
||||
);
|
||||
if (!hasHistoryForField) {
|
||||
return;
|
||||
}
|
||||
await showSensorHistorySheet(
|
||||
context,
|
||||
publicKeyHex: key,
|
||||
initialFieldKey: fieldKey,
|
||||
);
|
||||
},
|
||||
onShowMetHistory: (contact) =>
|
||||
showBTHomeMetHistorySheet(context, contact: contact),
|
||||
onMoveUp: isWatchedCard && index > 0
|
||||
|
||||
@@ -20,7 +20,6 @@ import '../models/contact.dart';
|
||||
import '../models/config_profile.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/locale_preferences.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../services/update_checker_service.dart';
|
||||
import '../services/voice_bitrate_preferences.dart';
|
||||
import '../services/image_preferences.dart';
|
||||
@@ -99,8 +98,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String _messageDestinationLockType =
|
||||
MessageDestinationPreferences.destinationTypeChannel;
|
||||
String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex;
|
||||
DateTime? _onlineTraceCacheUpdatedAt;
|
||||
bool _isClearingOnlineTraceCache = false;
|
||||
int _versionTapCount = 0;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
final LocationTrackingService _locationService = LocationTrackingService();
|
||||
@@ -119,7 +116,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_loadFastLocationSettings();
|
||||
_loadDeveloperMode();
|
||||
_loadProfilesEnabled();
|
||||
_loadOnlineTraceCacheStatus();
|
||||
_loadMapPreferences();
|
||||
_loadNotificationPreferences();
|
||||
_loadMessageDestinationLock();
|
||||
@@ -185,14 +181,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadOnlineTraceCacheStatus() async {
|
||||
final cachedAt = await MeshMapNodesService.cachedAt();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onlineTraceCacheUpdatedAt = cachedAt;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMessageDestinationLock() async {
|
||||
final lockedDestination =
|
||||
await MessageDestinationPreferences.getLockedDestination();
|
||||
@@ -1119,66 +1107,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearOnlineTraceCache() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.clearOnlineTraceDatabase),
|
||||
content: const Text(
|
||||
'This removes the cached online node database used as a trace fallback.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(AppLocalizations.of(context)!.clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isClearingOnlineTraceCache = true;
|
||||
});
|
||||
|
||||
await MeshMapNodesService.clearCache();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onlineTraceCacheUpdatedAt = null;
|
||||
_isClearingOnlineTraceCache = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.onlineTraceDatabaseCleared),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _onlineTraceCacheSubtitle() {
|
||||
final cachedAt = _onlineTraceCacheUpdatedAt;
|
||||
if (cachedAt == null) {
|
||||
return 'No cached online database. Refresh runs in background when internet is available.';
|
||||
}
|
||||
|
||||
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
|
||||
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime value) {
|
||||
final local = value.toLocal();
|
||||
String two(int part) => part.toString().padLeft(2, '0');
|
||||
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
|
||||
}
|
||||
|
||||
Future<void> _showRouteHashSizeDialog() async {
|
||||
final selected = await showDialog<int>(
|
||||
context: context,
|
||||
@@ -1614,11 +1542,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Map & Tracing ──
|
||||
// ── Map ──
|
||||
_buildSection(
|
||||
icon: Icons.map_rounded,
|
||||
title: AppLocalizations.of(context)!.map,
|
||||
subtitle: AppLocalizations.of(context)!.displayMarkersAndTraceDatabase,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
dense: true,
|
||||
@@ -1672,29 +1599,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await _saveMapPreference('map_show_debug_info', value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.cloud_sync, size: 20),
|
||||
title: Text(l10n.onlineTraceDatabase),
|
||||
subtitle: Text(
|
||||
_onlineTraceCacheSubtitle(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
trailing: _isClearingOnlineTraceCache
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: TextButton(
|
||||
onPressed: _clearOnlineTraceCache,
|
||||
child: const Text(
|
||||
'Clear',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ class MeshMapNodesService {
|
||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const int repeaterType = 1;
|
||||
static const Duration _cacheTtl = Duration(hours: 24);
|
||||
static const Duration traceCacheTtl = _cacheTtl;
|
||||
static const Duration traceTimeout = Duration(seconds: 30);
|
||||
static const Duration _requestTimeout = Duration(seconds: 30);
|
||||
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
|
||||
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
|
||||
static List<MeshMapNode>? _cachedNodes;
|
||||
@@ -74,7 +73,7 @@ class MeshMapNodesService {
|
||||
|
||||
final response = await (client ?? http.Client())
|
||||
.get(Uri.parse(_nodesEndpoint))
|
||||
.timeout(traceTimeout);
|
||||
.timeout(_requestTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Map nodes API returned ${response.statusCode}');
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
|
||||
class ResolvedTraceNode {
|
||||
final List<MeshMapNode> candidates;
|
||||
final int matchCount;
|
||||
final bool usedOnlineFallback;
|
||||
final int selectedIndex;
|
||||
|
||||
const ResolvedTraceNode({
|
||||
required this.candidates,
|
||||
required this.matchCount,
|
||||
required this.usedOnlineFallback,
|
||||
this.selectedIndex = 0,
|
||||
});
|
||||
|
||||
MeshMapNode? get node =>
|
||||
candidates.isEmpty ? null : candidates[selectedIndex];
|
||||
bool get hasMatch => node != null;
|
||||
bool get isAmbiguous => matchCount > 1;
|
||||
bool get canCycle => candidates.length > 1;
|
||||
|
||||
String? get matchSummary {
|
||||
if (matchCount <= 1) return null;
|
||||
final source = usedOnlineFallback ? 'online' : 'local';
|
||||
return '$matchCount $source matches';
|
||||
}
|
||||
|
||||
String? get cycleSummary =>
|
||||
canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null;
|
||||
|
||||
ResolvedTraceNode cycle() {
|
||||
if (!canCycle) return this;
|
||||
return ResolvedTraceNode(
|
||||
candidates: candidates,
|
||||
matchCount: matchCount,
|
||||
usedOnlineFallback: usedOnlineFallback,
|
||||
selectedIndex: (selectedIndex + 1) % candidates.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TraceNodeResolver {
|
||||
static const Distance _distance = Distance();
|
||||
|
||||
const TraceNodeResolver._();
|
||||
|
||||
static ResolvedTraceNode resolveBest({
|
||||
required List<MeshMapNode> nodes,
|
||||
required Set<String> localPublicKeys,
|
||||
required String? prefixHex,
|
||||
LatLng? referenceA,
|
||||
LatLng? referenceB,
|
||||
String? preferredPrefix,
|
||||
}) {
|
||||
if (prefixHex == null || prefixHex.isEmpty) {
|
||||
return const ResolvedTraceNode(
|
||||
candidates: <MeshMapNode>[],
|
||||
matchCount: 0,
|
||||
usedOnlineFallback: false,
|
||||
);
|
||||
}
|
||||
|
||||
final allMatches = nodes
|
||||
.where((n) => n.publicKey.startsWith(prefixHex))
|
||||
.toList();
|
||||
if (allMatches.isEmpty) {
|
||||
return const ResolvedTraceNode(
|
||||
candidates: <MeshMapNode>[],
|
||||
matchCount: 0,
|
||||
usedOnlineFallback: false,
|
||||
);
|
||||
}
|
||||
|
||||
final localMatches = allMatches
|
||||
.where((node) => localPublicKeys.contains(node.publicKey))
|
||||
.toList();
|
||||
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
|
||||
final usedOnlineFallback = localMatches.isEmpty;
|
||||
|
||||
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
|
||||
final preferredMatches = pool
|
||||
.where((node) => node.publicKey.startsWith(preferredPrefix))
|
||||
.toList();
|
||||
if (preferredMatches.isNotEmpty) {
|
||||
pool = preferredMatches;
|
||||
}
|
||||
}
|
||||
|
||||
pool.sort((a, b) {
|
||||
final distanceCompare =
|
||||
_scoreNode(
|
||||
a,
|
||||
referenceA: referenceA,
|
||||
referenceB: referenceB,
|
||||
).compareTo(
|
||||
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
|
||||
);
|
||||
if (distanceCompare != 0) return distanceCompare;
|
||||
return b.updatedAtMs.compareTo(a.updatedAtMs);
|
||||
});
|
||||
|
||||
return ResolvedTraceNode(
|
||||
candidates: List<MeshMapNode>.unmodifiable(pool),
|
||||
matchCount: pool.length,
|
||||
usedOnlineFallback: usedOnlineFallback,
|
||||
);
|
||||
}
|
||||
|
||||
static List<ResolvedTraceNode> alignPathSelections({
|
||||
required List<ResolvedTraceNode> nodes,
|
||||
MeshMapNode? startNode,
|
||||
MeshMapNode? endNode,
|
||||
}) {
|
||||
if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
final candidateCosts = List.generate(
|
||||
nodes.length,
|
||||
(_) => <double>[],
|
||||
growable: false,
|
||||
);
|
||||
final previousChoice = List.generate(
|
||||
nodes.length,
|
||||
(_) => <int>[],
|
||||
growable: false,
|
||||
);
|
||||
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
final currentCandidates = nodes[i].candidates;
|
||||
candidateCosts[i] = List<double>.filled(
|
||||
currentCandidates.length,
|
||||
double.infinity,
|
||||
);
|
||||
previousChoice[i] = List<int>.filled(currentCandidates.length, -1);
|
||||
|
||||
for (var j = 0; j < currentCandidates.length; j++) {
|
||||
final current = currentCandidates[j];
|
||||
if (i == 0) {
|
||||
candidateCosts[i][j] = startNode == null
|
||||
? 0
|
||||
: _distanceBetweenNodes(startNode, current);
|
||||
continue;
|
||||
}
|
||||
|
||||
final previousCandidates = nodes[i - 1].candidates;
|
||||
for (var k = 0; k < previousCandidates.length; k++) {
|
||||
final candidateCost =
|
||||
candidateCosts[i - 1][k] +
|
||||
_distanceBetweenNodes(previousCandidates[k], current);
|
||||
if (candidateCost < candidateCosts[i][j]) {
|
||||
candidateCosts[i][j] = candidateCost;
|
||||
previousChoice[i][j] = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bestLastIndex = 0;
|
||||
var bestLastCost = double.infinity;
|
||||
final lastCandidates = nodes.last.candidates;
|
||||
for (var i = 0; i < lastCandidates.length; i++) {
|
||||
final endCost = endNode == null
|
||||
? 0
|
||||
: _distanceBetweenNodes(lastCandidates[i], endNode);
|
||||
final totalCost = candidateCosts.last[i] + endCost;
|
||||
if (totalCost < bestLastCost) {
|
||||
bestLastCost = totalCost;
|
||||
bestLastIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
final selectedIndices = List<int>.filled(nodes.length, 0);
|
||||
selectedIndices[nodes.length - 1] = bestLastIndex;
|
||||
for (var i = nodes.length - 1; i > 0; i--) {
|
||||
selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
|
||||
}
|
||||
|
||||
return List<ResolvedTraceNode>.generate(nodes.length, (index) {
|
||||
final resolved = nodes[index];
|
||||
return ResolvedTraceNode(
|
||||
candidates: resolved.candidates,
|
||||
matchCount: resolved.matchCount,
|
||||
usedOnlineFallback: resolved.usedOnlineFallback,
|
||||
selectedIndex: selectedIndices[index],
|
||||
);
|
||||
}, growable: false);
|
||||
}
|
||||
|
||||
static double _scoreNode(
|
||||
MeshMapNode node, {
|
||||
LatLng? referenceA,
|
||||
LatLng? referenceB,
|
||||
}) {
|
||||
final point = LatLng(node.latitude, node.longitude);
|
||||
if (referenceA != null && referenceB != null) {
|
||||
return _distanceToSegmentMeters(point, referenceA, referenceB);
|
||||
}
|
||||
if (referenceA != null) {
|
||||
return _distance.as(LengthUnit.Meter, point, referenceA);
|
||||
}
|
||||
if (referenceB != null) {
|
||||
return _distance.as(LengthUnit.Meter, point, referenceB);
|
||||
}
|
||||
return double.maxFinite;
|
||||
}
|
||||
|
||||
static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
|
||||
return _distance.as(
|
||||
LengthUnit.Meter,
|
||||
LatLng(a.latitude, a.longitude),
|
||||
LatLng(b.latitude, b.longitude),
|
||||
);
|
||||
}
|
||||
|
||||
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||
final ax = a.longitude;
|
||||
final ay = a.latitude;
|
||||
final bx = b.longitude;
|
||||
final by = b.latitude;
|
||||
final px = p.longitude;
|
||||
final py = p.latitude;
|
||||
|
||||
final abx = bx - ax;
|
||||
final aby = by - ay;
|
||||
final apx = px - ax;
|
||||
final apy = py - ay;
|
||||
final ab2 = abx * abx + aby * aby;
|
||||
if (ab2 == 0) {
|
||||
return _distance.as(LengthUnit.Meter, a, p);
|
||||
}
|
||||
var t = (apx * abx + apy * aby) / ab2;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||
return _distance.as(LengthUnit.Meter, closest, p);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import '../../services/message_destination_preferences.dart';
|
||||
import '../../services/path_history_service.dart';
|
||||
import 'contact_route_dialog.dart';
|
||||
import 'ping_contact_sheet.dart';
|
||||
import 'contact_trace_sheet.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
import '../sensors/bthome_met_history_sheet.dart';
|
||||
@@ -644,15 +643,6 @@ class ContactTile extends StatelessWidget {
|
||||
await _addContactToSensors(context, contact);
|
||||
},
|
||||
),
|
||||
if (!contact.isChannel)
|
||||
_ContactSheetAction(
|
||||
icon: Icons.route,
|
||||
label: l10n.trace,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
_showTraceSheet(context, contact);
|
||||
},
|
||||
),
|
||||
if (contact.type == ContactType.repeater)
|
||||
_ContactSheetAction(
|
||||
icon: Icons.hub_outlined,
|
||||
@@ -805,18 +795,6 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTraceSheet(BuildContext context, Contact contact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => ContactTraceSheet(contact: contact),
|
||||
);
|
||||
}
|
||||
|
||||
void _pingRelay(BuildContext context, Contact contact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../services/mesh_map_nodes_service.dart';
|
||||
import '../../utils/trace_node_resolver.dart';
|
||||
|
||||
class ContactTraceSheet extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const ContactTraceSheet({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<ContactTraceSheet> createState() => _ContactTraceSheetState();
|
||||
}
|
||||
|
||||
class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
||||
late final Future<_ContactTraceResult> _future;
|
||||
_ContactTraceResult? _traceOverride;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _loadTrace();
|
||||
}
|
||||
|
||||
Future<_ContactTraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final localNodes = _localNodesFromContacts(
|
||||
contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
||||
|
||||
var trace = _buildTraceResult(
|
||||
nodes: localNodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
selfPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
if (_isCompleteTrace(trace)) {
|
||||
return trace;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
MeshMapNodesService.syncInBackgroundIfStale(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
),
|
||||
);
|
||||
|
||||
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
localPublicKeys: localPublicKeys,
|
||||
selfPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
return trace;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: FutureBuilder<_ContactTraceResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const SizedBox(
|
||||
height: 360,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return SizedBox(
|
||||
height: 360,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(AppLocalizations.of(context)!.failedToLoadTrace(snapshot.error.toString())),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final trace = _traceOverride ?? snapshot.data!;
|
||||
final routeEntries = _displayRouteEntries(trace);
|
||||
final concreteNodes = routeEntries
|
||||
.where((entry) => entry.resolved.node != null)
|
||||
.map((entry) => entry.resolved.node!)
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
final mapPoints = concreteNodes
|
||||
.map((node) => LatLng(node.latitude, node.longitude))
|
||||
.toList();
|
||||
final hasMapPath = mapPoints.length >= 2;
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: Text(
|
||||
'Trace',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
trace.routeHashes.isEmpty
|
||||
? 'No relay path saved for ${widget.contact.displayName}'
|
||||
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
height: 240,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: hasMapPath
|
||||
? flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit:
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds:
|
||||
flutter_map
|
||||
.LatLngBounds.fromPoints(
|
||||
mapPoints,
|
||||
),
|
||||
padding: const EdgeInsets.all(28),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName:
|
||||
'com.meshcore.sar',
|
||||
),
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: mapPoints,
|
||||
strokeWidth: 4,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: concreteNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) => flutter_map.Marker(
|
||||
point: LatLng(
|
||||
entry.value.latitude,
|
||||
entry.value.longitude,
|
||||
),
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
Colors.blue,
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Center(
|
||||
child: Text(
|
||||
'Not enough geolocated nodes to draw path',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Relay path',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (routeEntries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'No named nodes could be matched for this trace.',
|
||||
),
|
||||
),
|
||||
...routeEntries.asMap().entries.map(
|
||||
(entry) => ListTile(
|
||||
onTap: entry.value.resolved.canCycle
|
||||
? () => setState(() {
|
||||
final baseTrace =
|
||||
_traceOverride ?? snapshot.data!;
|
||||
_traceOverride = baseTrace.cycleEntry(
|
||||
entry.value.target,
|
||||
);
|
||||
})
|
||||
: null,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(entry.value.label),
|
||||
subtitle: Text(
|
||||
'Path node${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||
),
|
||||
trailing: entry.value.resolved.canCycle
|
||||
? const Icon(Icons.sync_alt)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
||||
return trace.matchedRelayNodes.asMap().entries.map((entry) {
|
||||
final resolved = entry.value;
|
||||
final node = resolved.node;
|
||||
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
||||
return _RouteDisplayEntry(
|
||||
resolved: resolved,
|
||||
label: node?.name ?? 'Unknown',
|
||||
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
|
||||
matchSummary: resolved.matchSummary,
|
||||
target: _RouteEntryTarget.relayNode(entry.key),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _prefixKeyLabel(String publicKey) =>
|
||||
publicKey.substring(0, math.min(12, publicKey.length));
|
||||
|
||||
bool _isCompleteTrace(_ContactTraceResult trace) {
|
||||
if (trace.sender.node == null || trace.recipient.node == null) {
|
||||
return false;
|
||||
}
|
||||
if (trace.routeHashes.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return trace.matchedRelayNodes.every((node) => node.node != null);
|
||||
}
|
||||
|
||||
_ContactTraceResult _buildTraceResult({
|
||||
required List<MeshMapNode> nodes,
|
||||
required Set<String> localPublicKeys,
|
||||
required List<int>? selfPublicKey,
|
||||
}) {
|
||||
final senderNode = TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: _toPrefixHex(selfPublicKey),
|
||||
);
|
||||
final recipientNode = TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: _toPrefixHex(widget.contact.publicKey),
|
||||
);
|
||||
final senderLatLng = senderNode.node == null
|
||||
? null
|
||||
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
|
||||
final recipientLatLng = recipientNode.node == null
|
||||
? null
|
||||
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
|
||||
final routeHashes =
|
||||
widget.contact.routeHasPath && widget.contact.routeHopCount > 0
|
||||
? widget.contact.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map((token) => token.toLowerCase())
|
||||
.toList()
|
||||
: const <String>[];
|
||||
|
||||
final matchedRelayNodes = routeHashes
|
||||
.map(
|
||||
(hash) => TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: hash,
|
||||
referenceA: senderLatLng,
|
||||
referenceB: recipientLatLng,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
|
||||
nodes: matchedRelayNodes,
|
||||
startNode: senderNode.node,
|
||||
endNode: recipientNode.node,
|
||||
);
|
||||
|
||||
return _ContactTraceResult(
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
routeHashes: routeHashes,
|
||||
matchedRelayNodes: alignedRelayNodes,
|
||||
);
|
||||
}
|
||||
|
||||
String? _toPrefixHex(List<int>? key) {
|
||||
if (key == null || key.isEmpty) return null;
|
||||
final take = key.length < 6 ? key.length : 6;
|
||||
return key
|
||||
.take(take)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
List<MeshMapNode> _localNodesFromContacts(
|
||||
ContactsProvider contactsProvider, {
|
||||
required ConnectionProvider connectionProvider,
|
||||
}) {
|
||||
final nodes = contactsProvider.contactsWithLocation
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return MeshMapNode(
|
||||
type: contact.type.index,
|
||||
name: contact.displayName,
|
||||
publicKey: contact.publicKeyHex.toLowerCase(),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
updatedAtMs: contact.lastAdvert * 1000,
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
|
||||
final selfNode = _selfNode(connectionProvider);
|
||||
if (selfNode != null) {
|
||||
nodes.add(selfNode);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
MeshMapNode? _selfNode(ConnectionProvider connectionProvider) {
|
||||
final publicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final advLat = connectionProvider.deviceInfo.advLat;
|
||||
final advLon = connectionProvider.deviceInfo.advLon;
|
||||
if (publicKey == null || advLat == null || advLon == null) {
|
||||
return null;
|
||||
}
|
||||
if (advLat == 0 && advLon == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final node = MeshMapNode(
|
||||
type: -1,
|
||||
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
|
||||
? connectionProvider.deviceInfo.selfName!.trim()
|
||||
: 'You',
|
||||
publicKey: publicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase(),
|
||||
latitude: advLat / 1e6,
|
||||
longitude: advLon / 1e6,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
return node.hasValidCoordinates ? node : null;
|
||||
}
|
||||
|
||||
List<MeshMapNode> _mergeNodes(
|
||||
List<MeshMapNode> preferred,
|
||||
List<MeshMapNode> fallback,
|
||||
) {
|
||||
final merged = <String, MeshMapNode>{};
|
||||
for (final node in fallback) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
for (final node in preferred) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
return merged.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactTraceResult {
|
||||
final ResolvedTraceNode sender;
|
||||
final ResolvedTraceNode recipient;
|
||||
final List<String> routeHashes;
|
||||
final List<ResolvedTraceNode> matchedRelayNodes;
|
||||
|
||||
const _ContactTraceResult({
|
||||
required this.sender,
|
||||
required this.recipient,
|
||||
required this.routeHashes,
|
||||
required this.matchedRelayNodes,
|
||||
});
|
||||
|
||||
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
|
||||
switch (target.kind) {
|
||||
case _RouteEntryKind.relayNode:
|
||||
final updated = matchedRelayNodes.toList();
|
||||
updated[target.index] = updated[target.index].cycle();
|
||||
return _ContactTraceResult(
|
||||
sender: sender,
|
||||
recipient: recipient,
|
||||
routeHashes: routeHashes,
|
||||
matchedRelayNodes: updated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteDisplayEntry {
|
||||
final ResolvedTraceNode resolved;
|
||||
final String label;
|
||||
final String? keyLabel;
|
||||
final String? matchSummary;
|
||||
final _RouteEntryTarget target;
|
||||
|
||||
const _RouteDisplayEntry({
|
||||
required this.resolved,
|
||||
required this.label,
|
||||
required this.keyLabel,
|
||||
required this.matchSummary,
|
||||
required this.target,
|
||||
});
|
||||
|
||||
MeshMapNode? get node => resolved.node;
|
||||
}
|
||||
|
||||
enum _RouteEntryKind { relayNode }
|
||||
|
||||
class _RouteEntryTarget {
|
||||
final _RouteEntryKind kind;
|
||||
final int index;
|
||||
|
||||
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||
|
||||
const _RouteEntryTarget.relayNode(int index)
|
||||
: this._(_RouteEntryKind.relayNode, index);
|
||||
}
|
||||
@@ -41,7 +41,6 @@ import '../../screens/add_contact_screen.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
import 'tictactoe_message_bubble.dart';
|
||||
import 'message_trace_sheet.dart';
|
||||
import 'message_bubble_header.dart';
|
||||
import 'message_bubble_signal.dart';
|
||||
import 'system_message_bubble.dart';
|
||||
@@ -544,17 +543,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_showTechnicalDetails(parentContext);
|
||||
},
|
||||
),
|
||||
if (!isOwnMessage &&
|
||||
widget.message.pathLen > 0 &&
|
||||
widget.message.pathLen < 255)
|
||||
ListTile(
|
||||
leading: Icon(Icons.route),
|
||||
title: Text(l10n.trace),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showTraceSheet(parentContext);
|
||||
},
|
||||
),
|
||||
// Delete message option
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.red),
|
||||
@@ -573,18 +561,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTraceSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => MessageTraceSheet(message: widget.message),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTechnicalDetails(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
// ignore_for_file: use_null_aware_elements
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../services/mesh_map_nodes_service.dart';
|
||||
import '../../services/route_hash_preferences.dart';
|
||||
import '../../utils/log_rx_route_decoder.dart';
|
||||
import '../../utils/trace_node_resolver.dart';
|
||||
|
||||
class MessageTraceSheet extends StatefulWidget {
|
||||
final Message? message;
|
||||
final List<int>? packetPathOverride;
|
||||
final String? descriptionOverride;
|
||||
final String? noRelayMatchTextOverride;
|
||||
|
||||
const MessageTraceSheet({super.key, required this.message})
|
||||
: assert(message != null),
|
||||
packetPathOverride = null,
|
||||
descriptionOverride = null,
|
||||
noRelayMatchTextOverride = null;
|
||||
|
||||
const MessageTraceSheet.packetPath({
|
||||
super.key,
|
||||
required List<int> packetPath,
|
||||
this.descriptionOverride,
|
||||
this.noRelayMatchTextOverride,
|
||||
}) : message = null,
|
||||
packetPathOverride = packetPath;
|
||||
|
||||
@override
|
||||
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
|
||||
}
|
||||
|
||||
class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
late final Future<_TraceResult> _future;
|
||||
_TraceResult? _traceOverride;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _loadTrace();
|
||||
}
|
||||
|
||||
Future<_TraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
||||
List<int>? packetPath = widget.packetPathOverride;
|
||||
String? senderPrefix;
|
||||
String? recipientPrefix;
|
||||
|
||||
if (widget.message case final message?) {
|
||||
final storedPath = messagesProvider
|
||||
.getMessageReceptionDetails(message.id)
|
||||
?.pathBytes;
|
||||
packetPath = (storedPath != null && storedPath.isNotEmpty)
|
||||
? storedPath
|
||||
: _extractPathFromPacketLogs(
|
||||
logs: connectionProvider.bleService.packetLogs,
|
||||
message: message,
|
||||
);
|
||||
|
||||
senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
|
||||
recipientPrefix = message.recipientPublicKey != null
|
||||
? _toPrefixHex(message.recipientPublicKey)
|
||||
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
||||
}
|
||||
|
||||
final localNodes = _localNodesFromContacts(contactsProvider);
|
||||
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
||||
var trace = _buildTraceResult(
|
||||
nodes: localNodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
packetPath: packetPath,
|
||||
preferredHashSize: preferredHashSize,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
if (_isCompleteTrace(
|
||||
trace,
|
||||
expectedRelayCount: widget.message == null
|
||||
? 0
|
||||
: math.max(0, widget.message!.pathLen),
|
||||
)) {
|
||||
return trace;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
MeshMapNodesService.syncInBackgroundIfStale(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
),
|
||||
);
|
||||
|
||||
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
localPublicKeys: localPublicKeys,
|
||||
packetPath: packetPath,
|
||||
preferredHashSize: preferredHashSize,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return trace;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: FutureBuilder<_TraceResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const SizedBox(
|
||||
height: 360,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return SizedBox(
|
||||
height: 360,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(AppLocalizations.of(context)!.failedToLoadTrace(snapshot.error.toString())),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final trace = _traceOverride ?? snapshot.data!;
|
||||
final routeEntries = _displayRouteEntries(trace);
|
||||
final concretePathNodes = routeEntries
|
||||
.where((entry) => entry.resolved.node != null)
|
||||
.map((entry) => entry.resolved.node!)
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
final mapPoints = concretePathNodes
|
||||
.map((n) => LatLng(n.latitude, n.longitude))
|
||||
.toList();
|
||||
final hasMapPath = mapPoints.length >= 2;
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: Text(
|
||||
'Trace',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
widget.descriptionOverride ??
|
||||
(trace.mode == TraceMode.packetPath
|
||||
? 'Relay path from packet path bytes'
|
||||
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
height: 240,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: hasMapPath
|
||||
? flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit:
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds:
|
||||
flutter_map
|
||||
.LatLngBounds.fromPoints(
|
||||
mapPoints,
|
||||
),
|
||||
padding: const EdgeInsets.all(28),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName:
|
||||
'com.meshcore.sar',
|
||||
),
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: mapPoints,
|
||||
strokeWidth: 4,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: concretePathNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) => flutter_map.Marker(
|
||||
point: LatLng(
|
||||
entry.value.latitude,
|
||||
entry.value.longitude,
|
||||
),
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
Colors.blue,
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Center(
|
||||
child: Text(
|
||||
'Not enough geolocated nodes to draw path',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Relay path',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (routeEntries.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
widget.noRelayMatchTextOverride ??
|
||||
'No named nodes could be matched for this trace.',
|
||||
),
|
||||
),
|
||||
...routeEntries.asMap().entries.map(
|
||||
(entry) => ListTile(
|
||||
onTap: entry.value.resolved.canCycle
|
||||
? () => setState(() {
|
||||
final baseTrace =
|
||||
_traceOverride ?? snapshot.data!;
|
||||
_traceOverride = baseTrace.cycleEntry(
|
||||
entry.value.target,
|
||||
);
|
||||
})
|
||||
: null,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(entry.value.label),
|
||||
subtitle: Text(
|
||||
'Path node${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||
),
|
||||
trailing: entry.value.resolved.canCycle
|
||||
? const Icon(Icons.sync_alt)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
return trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
||||
return _RouteDisplayEntry(
|
||||
resolved: entry.value,
|
||||
label: entry.value.node?.name ?? 'Unknown',
|
||||
keyLabel: entry.value.node != null
|
||||
? _prefixKeyLabel(entry.value.node!.publicKey)
|
||||
: hashHex,
|
||||
matchSummary: entry.value.matchSummary,
|
||||
target: _RouteEntryTarget.pathNode(entry.key),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return trace.matchedPathNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.where((entry) => entry.value.node != null)
|
||||
.map(
|
||||
(entry) => _RouteDisplayEntry.fromResolved(
|
||||
entry.value,
|
||||
target: _RouteEntryTarget.pathNode(entry.key),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _prefixKeyLabel(String publicKey) =>
|
||||
publicKey.substring(0, math.min(12, publicKey.length));
|
||||
|
||||
String? _toPrefixHex(List<int>? key) {
|
||||
if (key == null || key.isEmpty) return null;
|
||||
final take = key.length < 6 ? key.length : 6;
|
||||
return key
|
||||
.take(take)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
|
||||
return contactsProvider.contactsWithLocation
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return MeshMapNode(
|
||||
type: contact.type.index,
|
||||
name: contact.displayName,
|
||||
publicKey: contact.publicKeyHex.toLowerCase(),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
updatedAtMs: contact.lastAdvert * 1000,
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<MeshMapNode> _mergeNodes(
|
||||
List<MeshMapNode> preferred,
|
||||
List<MeshMapNode> fallback,
|
||||
) {
|
||||
final merged = <String, MeshMapNode>{};
|
||||
for (final node in fallback) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
for (final node in preferred) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
return merged.values.toList();
|
||||
}
|
||||
|
||||
_TraceResult _buildTraceResult({
|
||||
required List<MeshMapNode> nodes,
|
||||
required Set<String> localPublicKeys,
|
||||
required List<int>? packetPath,
|
||||
required int preferredHashSize,
|
||||
required String? senderPrefix,
|
||||
required String? recipientPrefix,
|
||||
}) {
|
||||
final senderNode = TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: senderPrefix,
|
||||
);
|
||||
final recipientNode = TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: recipientPrefix,
|
||||
);
|
||||
final senderLatLng = senderNode.node == null
|
||||
? null
|
||||
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
|
||||
final recipientLatLng = recipientNode.node == null
|
||||
? null
|
||||
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
|
||||
|
||||
if (packetPath != null && packetPath.isNotEmpty) {
|
||||
final hashSize = LogRxRouteDecoder.inferHashSize(
|
||||
packetPath,
|
||||
preferredHashSize: preferredHashSize,
|
||||
);
|
||||
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
||||
packetPath,
|
||||
hashSize: hashSize,
|
||||
);
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
pathHashes: hopHashes,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
senderLatLng: senderLatLng,
|
||||
recipientLatLng: recipientLatLng,
|
||||
);
|
||||
final alignedMatched = TraceNodeResolver.alignPathSelections(
|
||||
nodes: matched,
|
||||
startNode: senderNode.node,
|
||||
endNode: recipientNode.node,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: hopHashes,
|
||||
matchedPathNodes: alignedMatched,
|
||||
);
|
||||
}
|
||||
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode.node,
|
||||
recipient: recipientNode.node,
|
||||
relayCount: math.max(0, widget.message!.pathLen),
|
||||
);
|
||||
final matchedPathNodes = <ResolvedTraceNode>[
|
||||
if (senderNode.node != null) senderNode,
|
||||
...inferred.map(
|
||||
(node) => ResolvedTraceNode(
|
||||
candidates: [node],
|
||||
matchCount: 1,
|
||||
usedOnlineFallback: false,
|
||||
),
|
||||
),
|
||||
if (recipientNode.node != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
|
||||
if (trace.sender.node == null || trace.recipient.node == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
return trace.matchedPathNodes.length == trace.pathHashes.length &&
|
||||
trace.matchedPathNodes.every((node) => node.node != null);
|
||||
}
|
||||
|
||||
final concreteCount = trace.matchedPathNodes
|
||||
.map((entry) => entry.node)
|
||||
.whereType<MeshMapNode>()
|
||||
.length;
|
||||
return concreteCount >= expectedRelayCount + 2;
|
||||
}
|
||||
|
||||
List<int>? _extractPathFromPacketLogs({
|
||||
required List<BlePacketLog> logs,
|
||||
required Message message,
|
||||
}) {
|
||||
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
|
||||
final expectedPayloadType = message.messageType == MessageType.channel
|
||||
? 0x05
|
||||
: 0x02;
|
||||
BlePacketLog? bestLog;
|
||||
var bestDeltaMs = 999999999;
|
||||
|
||||
for (final log in logs) {
|
||||
if (log.responseCode != 0x88) continue; // pushLogRxData
|
||||
if (log.rawData.length < 6) continue;
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null) continue;
|
||||
if (decoded.payloadType != expectedPayloadType) continue;
|
||||
if (decoded.hopCount != message.pathLen) continue;
|
||||
|
||||
final deltaMs =
|
||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||
if (deltaMs < bestDeltaMs) {
|
||||
bestDeltaMs = deltaMs;
|
||||
bestLog = log;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestLog == null || bestDeltaMs > 30000) return null;
|
||||
final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
|
||||
if (decoded == null || decoded.pathBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return decoded.pathBytes;
|
||||
}
|
||||
|
||||
List<ResolvedTraceNode> _matchNodesFromPathHashes({
|
||||
required List<MeshMapNode> nodes,
|
||||
required Set<String> localPublicKeys,
|
||||
required List<String> pathHashes,
|
||||
required String? senderPrefix,
|
||||
required String? recipientPrefix,
|
||||
required LatLng? senderLatLng,
|
||||
required LatLng? recipientLatLng,
|
||||
}) {
|
||||
final result = <ResolvedTraceNode>[];
|
||||
for (var i = 0; i < pathHashes.length; i++) {
|
||||
final hashHex = pathHashes[i].toLowerCase();
|
||||
result.add(
|
||||
TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: hashHex,
|
||||
preferredPrefix: i == 0
|
||||
? senderPrefix
|
||||
: (i == pathHashes.length - 1 ? recipientPrefix : null),
|
||||
referenceA: senderLatLng,
|
||||
referenceB: recipientLatLng,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
List<MeshMapNode> _inferRelaysFromHopCount({
|
||||
required List<MeshMapNode> nodes,
|
||||
required MeshMapNode? sender,
|
||||
required MeshMapNode? recipient,
|
||||
required int relayCount,
|
||||
}) {
|
||||
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
||||
final candidates = nodes.where((n) {
|
||||
if (sender.publicKey == n.publicKey ||
|
||||
recipient.publicKey == n.publicKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final ranked = candidates
|
||||
..sort((a, b) {
|
||||
final da = _distanceToSegmentMeters(
|
||||
p: LatLng(a.latitude, a.longitude),
|
||||
a: LatLng(sender.latitude, sender.longitude),
|
||||
b: LatLng(recipient.latitude, recipient.longitude),
|
||||
);
|
||||
final db = _distanceToSegmentMeters(
|
||||
p: LatLng(b.latitude, b.longitude),
|
||||
a: LatLng(sender.latitude, sender.longitude),
|
||||
b: LatLng(recipient.latitude, recipient.longitude),
|
||||
);
|
||||
return da.compareTo(db);
|
||||
});
|
||||
|
||||
return ranked.take(relayCount).toList();
|
||||
}
|
||||
|
||||
double _distanceToSegmentMeters({
|
||||
required LatLng p,
|
||||
required LatLng a,
|
||||
required LatLng b,
|
||||
}) {
|
||||
final ax = a.longitude;
|
||||
final ay = a.latitude;
|
||||
final bx = b.longitude;
|
||||
final by = b.latitude;
|
||||
final px = p.longitude;
|
||||
final py = p.latitude;
|
||||
|
||||
final abx = bx - ax;
|
||||
final aby = by - ay;
|
||||
final apx = px - ax;
|
||||
final apy = py - ay;
|
||||
final ab2 = abx * abx + aby * aby;
|
||||
if (ab2 == 0) {
|
||||
return const Distance().as(LengthUnit.Meter, a, p);
|
||||
}
|
||||
var t = (apx * abx + apy * aby) / ab2;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||
return const Distance().as(LengthUnit.Meter, closest, p);
|
||||
}
|
||||
}
|
||||
|
||||
enum TraceMode { packetPath, hopCountInference }
|
||||
|
||||
class _TraceResult {
|
||||
final TraceMode mode;
|
||||
final ResolvedTraceNode sender;
|
||||
final ResolvedTraceNode recipient;
|
||||
final List<String> pathHashes;
|
||||
final List<ResolvedTraceNode> matchedPathNodes;
|
||||
|
||||
const _TraceResult({
|
||||
required this.mode,
|
||||
required this.sender,
|
||||
required this.recipient,
|
||||
required this.pathHashes,
|
||||
required this.matchedPathNodes,
|
||||
});
|
||||
|
||||
_TraceResult cycleEntry(_RouteEntryTarget target) {
|
||||
switch (target.kind) {
|
||||
case _RouteEntryKind.pathNode:
|
||||
final updated = matchedPathNodes.toList();
|
||||
updated[target.index] = updated[target.index].cycle();
|
||||
return _TraceResult(
|
||||
mode: mode,
|
||||
sender: sender,
|
||||
recipient: recipient,
|
||||
pathHashes: pathHashes,
|
||||
matchedPathNodes: updated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteDisplayEntry {
|
||||
final ResolvedTraceNode resolved;
|
||||
final String label;
|
||||
final String? keyLabel;
|
||||
final String? matchSummary;
|
||||
final _RouteEntryTarget target;
|
||||
|
||||
const _RouteDisplayEntry({
|
||||
required this.resolved,
|
||||
required this.label,
|
||||
required this.keyLabel,
|
||||
required this.matchSummary,
|
||||
required this.target,
|
||||
});
|
||||
|
||||
MeshMapNode? get node => resolved.node;
|
||||
|
||||
factory _RouteDisplayEntry.fromResolved(
|
||||
ResolvedTraceNode resolved, {
|
||||
required _RouteEntryTarget target,
|
||||
}) {
|
||||
final node = resolved.node!;
|
||||
return _RouteDisplayEntry(
|
||||
resolved: resolved,
|
||||
label: node.name,
|
||||
keyLabel: node.publicKey.substring(
|
||||
0,
|
||||
math.min(12, node.publicKey.length),
|
||||
),
|
||||
matchSummary: resolved.matchSummary,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _RouteEntryKind { pathNode }
|
||||
|
||||
class _RouteEntryTarget {
|
||||
final _RouteEntryKind kind;
|
||||
final int index;
|
||||
|
||||
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||
|
||||
const _RouteEntryTarget.pathNode(int index)
|
||||
: this._(_RouteEntryKind.pathNode, index);
|
||||
}
|
||||
628
lib/widgets/sensors/sensor_history_sheet.dart
Normal file
628
lib/widgets/sensors/sensor_history_sheet.dart
Normal file
@@ -0,0 +1,628 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/sensors_provider.dart';
|
||||
import 'sensor_telemetry_card.dart';
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SensorHistorySheet extends StatefulWidget {
|
||||
const _SensorHistorySheet({
|
||||
required this.publicKeyHex,
|
||||
this.initialFieldKey,
|
||||
});
|
||||
|
||||
final String publicKeyHex;
|
||||
final String? initialFieldKey;
|
||||
|
||||
@override
|
||||
State<_SensorHistorySheet> createState() => _SensorHistorySheetState();
|
||||
}
|
||||
|
||||
class _SensorHistorySheetState extends State<_SensorHistorySheet> {
|
||||
String? _selectedFieldKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedFieldKey = widget.initialFieldKey;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.of(context).size.height * 0.84;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
_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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sensor history',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
contact?.displayName ?? 'Unavailable node',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (availableFieldKeys.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: availableFieldKeys
|
||||
.map((fieldKey) {
|
||||
final option = optionByKey[fieldKey];
|
||||
return ChoiceChip(
|
||||
label: Text(
|
||||
option?.defaultLabel ?? fieldKey,
|
||||
),
|
||||
selected: fieldKey == selectedFieldKey,
|
||||
onSelected: (selected) {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedFieldKey = fieldKey;
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SensorHistorySummaryCard(
|
||||
historyCount: history.length,
|
||||
samples: selectedSamples,
|
||||
cardData: selectedCardData,
|
||||
fieldKey: selectedFieldKey!,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedCardData?.label ??
|
||||
selectedOption?.defaultLabel ??
|
||||
selectedFieldKey,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: LineChart(
|
||||
_historyLineChartData(
|
||||
context,
|
||||
samples: selectedSamples,
|
||||
fieldKey: selectedFieldKey,
|
||||
color:
|
||||
selectedCardData?.accent ??
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: _SensorHistoryLogList(
|
||||
history: history.reversed.toList(growable: false),
|
||||
selectedFieldKey: selectedFieldKey,
|
||||
optionByKey: optionByKey,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? resolveInitialSensorHistoryField({
|
||||
required String? requestedFieldKey,
|
||||
required List<String> availableFieldKeys,
|
||||
}) {
|
||||
if (requestedFieldKey != null &&
|
||||
availableFieldKeys.contains(requestedFieldKey)) {
|
||||
return requestedFieldKey;
|
||||
}
|
||||
if (availableFieldKeys.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return availableFieldKeys.first;
|
||||
}
|
||||
|
||||
class _SensorHistorySummaryCard extends StatelessWidget {
|
||||
const _SensorHistorySummaryCard({
|
||||
required this.historyCount,
|
||||
required this.samples,
|
||||
required this.cardData,
|
||||
required this.fieldKey,
|
||||
});
|
||||
|
||||
final int historyCount;
|
||||
final List<SensorHistorySample> samples;
|
||||
final SensorMetricCardData? cardData;
|
||||
final String fieldKey;
|
||||
|
||||
@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);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final accent = cardData?.accent ?? theme.colorScheme.primary;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Total',
|
||||
value: historyCount.toString(),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Latest',
|
||||
value: latestValue == null
|
||||
? '--'
|
||||
: _formatHistoryValue(cardData, latestValue),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorHistoryStatTile extends StatelessWidget {
|
||||
const _SensorHistoryStatTile({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
required String fieldKey,
|
||||
required Color color,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final values = samples
|
||||
.map((sample) => sample.values[fieldKey]!)
|
||||
.toList(growable: false);
|
||||
final spots = values
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
|
||||
.toList(growable: false);
|
||||
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 minY = minValue - padding;
|
||||
final maxY = maxValue + padding;
|
||||
final interval = spread <= 0 ? math.max(maxValue.abs() / 3, 1.0) : spread / 3;
|
||||
|
||||
return LineChartData(
|
||||
minX: 0,
|
||||
maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(),
|
||||
minY: minY,
|
||||
maxY: maxY,
|
||||
clipData: const FlClipData.all(),
|
||||
lineTouchData: const LineTouchData(enabled: false),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: interval,
|
||||
getDrawingHorizontalLine: (value) => FlLine(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.24),
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: interval,
|
||||
getTitlesWidget: (value, meta) => SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
value.toStringAsFixed(value.abs() >= 10 ? 0 : 1),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 24,
|
||||
interval: math.max((values.length / 4).floorToDouble(), 1),
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.round();
|
||||
if (index < 0 || index >= samples.length || value != index.toDouble()) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 6,
|
||||
child: Text(
|
||||
_formatChartTimestamp(samples[index].timestamp),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
color: color,
|
||||
barWidth: 2.8,
|
||||
isCurved: false,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
checkToShowDot: (spot, barData) =>
|
||||
barData.spots.length <= 10 || spot == barData.spots.last,
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
color.withValues(alpha: 0.20),
|
||||
color.withValues(alpha: 0.03),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatHistoryValue(SensorMetricCardData? cardData, double value) {
|
||||
final template = cardData?.value;
|
||||
if (template == null || template.isEmpty) {
|
||||
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
|
||||
}
|
||||
|
||||
if (template.endsWith('%')) {
|
||||
return '${value.toStringAsFixed(1)}%';
|
||||
}
|
||||
if (template.endsWith('V')) {
|
||||
return '${value.toStringAsFixed(3)}V';
|
||||
}
|
||||
if (template.contains(' hPa')) {
|
||||
return '${value.toStringAsFixed(1)} hPa';
|
||||
}
|
||||
if (template.contains('°C')) {
|
||||
return '${value.toStringAsFixed(1)}°C';
|
||||
}
|
||||
if (template.contains(' mph')) {
|
||||
return '${value.toStringAsFixed(2)} mph';
|
||||
}
|
||||
if (template.contains(' km/h')) {
|
||||
return '${value.toStringAsFixed(2)} km/h';
|
||||
}
|
||||
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
|
||||
}
|
||||
|
||||
String _formatSampleTimestamp(DateTime timestamp) {
|
||||
final local = timestamp.toLocal();
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final year = local.year.toString().substring(2);
|
||||
final hour = local.hour.toString().padLeft(2, '0');
|
||||
final minute = local.minute.toString().padLeft(2, '0');
|
||||
return '$month/$day/$year $hour:$minute';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
@@ -1067,6 +1066,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
final Future<void> Function()? onPing;
|
||||
final VoidCallback? onCustomize;
|
||||
final Future<void> Function(Contact contact)? onShowMetHistory;
|
||||
final Future<void> Function(String fieldKey)? onMetricTap;
|
||||
final Future<void> Function()? onMoveUp;
|
||||
final Future<void> Function()? onMoveDown;
|
||||
final EdgeInsetsGeometry margin;
|
||||
@@ -1086,6 +1086,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
this.onPing,
|
||||
this.onCustomize,
|
||||
this.onShowMetHistory,
|
||||
this.onMetricTap,
|
||||
this.onMoveUp,
|
||||
this.onMoveDown,
|
||||
this.margin = const EdgeInsets.only(bottom: 16),
|
||||
@@ -1112,7 +1113,6 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
onRemove != null ||
|
||||
onMoveUp != null ||
|
||||
onMoveDown != null ||
|
||||
_rawTelemetryHex(contact?.telemetry) != null ||
|
||||
(contact != null &&
|
||||
onShowMetHistory != null &&
|
||||
supportsBTHomeMetHistory(contact));
|
||||
@@ -1134,18 +1134,6 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
await onMoveDown!();
|
||||
return;
|
||||
}
|
||||
if (value == 'copy_raw') {
|
||||
final rawTelemetry = _rawTelemetryHex(contact?.telemetry);
|
||||
if (rawTelemetry != null && context.mounted) {
|
||||
await Clipboard.setData(ClipboardData(text: rawTelemetry));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.rawResponseCopied)),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value == 'remove' && onRemove != null) {
|
||||
await onRemove!();
|
||||
return;
|
||||
@@ -1176,18 +1164,24 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
label: AppLocalizations.of(context)!.ping,
|
||||
onTap: () => _handleAction(context, 'ping'),
|
||||
),
|
||||
if (_rawTelemetryHex(contact?.telemetry) != null)
|
||||
_SensorSheetAction(
|
||||
icon: Icons.copy_all_outlined,
|
||||
label: AppLocalizations.of(context)!.copyRawResponse,
|
||||
onTap: () => _handleAction(context, 'copy_raw'),
|
||||
),
|
||||
if (onCustomize != null)
|
||||
_SensorSheetAction(
|
||||
icon: Icons.tune,
|
||||
label: l10n.customizeFields,
|
||||
onTap: () => _handleAction(context, 'customize'),
|
||||
),
|
||||
if (onMoveUp != null)
|
||||
_SensorSheetAction(
|
||||
icon: Icons.arrow_upward,
|
||||
label: l10n.moveUp,
|
||||
onTap: () => _handleAction(context, 'move_up'),
|
||||
),
|
||||
if (onMoveDown != null)
|
||||
_SensorSheetAction(
|
||||
icon: Icons.arrow_downward,
|
||||
label: l10n.moveDown,
|
||||
onTap: () => _handleAction(context, 'move_down'),
|
||||
),
|
||||
if (contact != null &&
|
||||
onShowMetHistory != null &&
|
||||
supportsBTHomeMetHistory(contact))
|
||||
@@ -1327,12 +1321,15 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (_showsMenu)
|
||||
Icon(
|
||||
showActionSheetOnTap
|
||||
? Icons.chevron_right_rounded
|
||||
: Icons.more_horiz,
|
||||
IconButton(
|
||||
onPressed: () => _showActionSheet(context),
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -1364,6 +1361,11 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
metric.wide)
|
||||
? constraints.maxWidth
|
||||
: compactWidth,
|
||||
onTap: onMetricTap == null
|
||||
? null
|
||||
: () async {
|
||||
await onMetricTap!(metric.fieldKey);
|
||||
},
|
||||
onLongPress: onRefresh == null
|
||||
? null
|
||||
: () async {
|
||||
@@ -1380,17 +1382,9 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
if (!_showsMenu || !showActionSheetOnTap) {
|
||||
return card;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _showActionSheet(context),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
List<SensorMetricCardData> _buildMetricCards(
|
||||
AppLocalizations l10n,
|
||||
dynamic telemetry,
|
||||
@@ -2326,6 +2320,7 @@ class SensorMetricTile extends StatefulWidget {
|
||||
final double width;
|
||||
final String keyPrefix;
|
||||
final bool allowMapPreview;
|
||||
final GestureTapCallback? onTap;
|
||||
final GestureLongPressCallback? onLongPress;
|
||||
|
||||
const SensorMetricTile({
|
||||
@@ -2334,6 +2329,7 @@ class SensorMetricTile extends StatefulWidget {
|
||||
required this.width,
|
||||
this.keyPrefix = 'sensor_metric',
|
||||
this.allowMapPreview = true,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@@ -2450,6 +2446,7 @@ class _SensorMetricTileState extends State<SensorMetricTile> {
|
||||
child: InkWell(
|
||||
key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
@@ -3102,11 +3099,6 @@ bool _isTelemetryMetadataKey(String key) {
|
||||
key == _rawTelemetryHexKey;
|
||||
}
|
||||
|
||||
String? _rawTelemetryHex(ContactTelemetry? telemetry) {
|
||||
final value = telemetry?.extraSensorData?[_rawTelemetryHexKey];
|
||||
return value is String && value.trim().isNotEmpty ? value : null;
|
||||
}
|
||||
|
||||
String _telemetrySourceChannelKey(String fieldKey) {
|
||||
return '$_telemetrySourceChannelPrefix$fieldKey';
|
||||
}
|
||||
|
||||
@@ -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.0408.1+51
|
||||
version: 2026.0410.1+52
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||
@@ -215,6 +215,70 @@ void main() {
|
||||
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
|
||||
});
|
||||
|
||||
test('tracked auto refresh history is captured and persisted', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final timestamp = DateTime(2026, 3, 15, 9, 0);
|
||||
final contacts = <Contact>[
|
||||
buildSensorContact().copyWith(
|
||||
telemetry: ContactTelemetry(
|
||||
temperature: 12.5,
|
||||
humidity: 54,
|
||||
pressure: 918.2,
|
||||
timestamp: timestamp,
|
||||
extraSensorData: const {'illuminance_2': 150.0},
|
||||
),
|
||||
),
|
||||
];
|
||||
final contactsProvider = _FakeContactsProvider(contacts);
|
||||
final connectionProvider = _FakeConnectionProvider(isConnected: true);
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contacts.first);
|
||||
await provider.setAutoRefreshMinutes(contacts.first.publicKeyHex, 5);
|
||||
|
||||
await provider.captureTrackedTelemetryHistory(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
|
||||
final firstHistory = provider.historyFor(contacts.first.publicKeyHex);
|
||||
expect(firstHistory, hasLength(1));
|
||||
expect(firstHistory.first.values['temperature'], 12.5);
|
||||
expect(firstHistory.first.values['humidity'], 54);
|
||||
expect(firstHistory.first.values['pressure'], 918.2);
|
||||
expect(firstHistory.first.values['extra:illuminance_2'], 150.0);
|
||||
|
||||
await provider.captureTrackedTelemetryHistory(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(provider.historyFor(contacts.first.publicKeyHex), hasLength(1));
|
||||
|
||||
contacts[0] = contacts[0].copyWith(
|
||||
telemetry: ContactTelemetry(
|
||||
temperature: 13.1,
|
||||
humidity: 52,
|
||||
pressure: 919.0,
|
||||
timestamp: timestamp.add(const Duration(minutes: 5)),
|
||||
extraSensorData: const {'illuminance_2': 160.0},
|
||||
),
|
||||
);
|
||||
|
||||
await provider.captureTrackedTelemetryHistory(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
|
||||
final updatedHistory = provider.historyFor(contacts.first.publicKeyHex);
|
||||
expect(updatedHistory, hasLength(2));
|
||||
expect(updatedHistory.last.values['temperature'], 13.1);
|
||||
|
||||
final reloadedProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedProvider);
|
||||
expect(reloadedProvider.historyFor(contacts.first.publicKeyHex), hasLength(2));
|
||||
});
|
||||
|
||||
test('watched sensors and preferences are isolated per profile', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildSensorContact();
|
||||
|
||||
@@ -69,7 +69,7 @@ void main() {
|
||||
expect(await MeshMapNodesService.hasFreshCache(), isFalse);
|
||||
});
|
||||
|
||||
test('clearCache removes persisted online trace database', () async {
|
||||
test('clearCache removes persisted online node cache', () async {
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
|
||||
import 'package:meshcore_sar_app/utils/trace_node_resolver.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'prefers closest local repeater over online fallback for shared prefix',
|
||||
() {
|
||||
final localNear = _node(
|
||||
name: 'Near Local',
|
||||
publicKey: 'aa1100',
|
||||
latitude: 46.05,
|
||||
longitude: 14.50,
|
||||
);
|
||||
final localFar = _node(
|
||||
name: 'Far Local',
|
||||
publicKey: 'aa2200',
|
||||
latitude: 46.40,
|
||||
longitude: 14.90,
|
||||
);
|
||||
final online = _node(
|
||||
name: 'Online',
|
||||
publicKey: 'aa3300',
|
||||
latitude: 46.06,
|
||||
longitude: 14.51,
|
||||
);
|
||||
|
||||
final resolved = TraceNodeResolver.resolveBest(
|
||||
nodes: [localNear, localFar, online],
|
||||
localPublicKeys: {localNear.publicKey, localFar.publicKey},
|
||||
prefixHex: 'aa',
|
||||
referenceA: const LatLng(46.0, 14.5),
|
||||
referenceB: const LatLng(46.1, 14.5),
|
||||
);
|
||||
|
||||
expect(resolved.node?.name, 'Near Local');
|
||||
expect(resolved.usedOnlineFallback, isFalse);
|
||||
expect(resolved.matchCount, 2);
|
||||
expect(resolved.matchSummary, '2 local matches');
|
||||
},
|
||||
);
|
||||
|
||||
test('falls back to online node only when local match is missing', () {
|
||||
final online = _node(
|
||||
name: 'Online Only',
|
||||
publicKey: 'bb1100',
|
||||
latitude: 46.06,
|
||||
longitude: 14.51,
|
||||
);
|
||||
|
||||
final resolved = TraceNodeResolver.resolveBest(
|
||||
nodes: [online],
|
||||
localPublicKeys: const {},
|
||||
prefixHex: 'bb',
|
||||
referenceA: const LatLng(46.0, 14.5),
|
||||
referenceB: const LatLng(46.1, 14.5),
|
||||
);
|
||||
|
||||
expect(resolved.node?.name, 'Online Only');
|
||||
expect(resolved.usedOnlineFallback, isTrue);
|
||||
expect(resolved.matchCount, 1);
|
||||
});
|
||||
|
||||
test('cycles through ambiguous local prefix matches', () {
|
||||
final first = _node(
|
||||
name: 'First Match',
|
||||
publicKey: 'cc1100',
|
||||
latitude: 46.08,
|
||||
longitude: 14.52,
|
||||
);
|
||||
final second = _node(
|
||||
name: 'Second Match',
|
||||
publicKey: 'cc11ff',
|
||||
latitude: 46.09,
|
||||
longitude: 14.53,
|
||||
);
|
||||
|
||||
final resolved = TraceNodeResolver.resolveBest(
|
||||
nodes: [second, first],
|
||||
localPublicKeys: {first.publicKey, second.publicKey},
|
||||
prefixHex: 'cc11',
|
||||
);
|
||||
|
||||
expect(resolved.matchCount, 2);
|
||||
expect(resolved.canCycle, isTrue);
|
||||
expect(resolved.node?.name, 'Second Match');
|
||||
expect(resolved.cycle().node?.name, 'First Match');
|
||||
expect(resolved.cycle().cycle().node?.name, 'Second Match');
|
||||
});
|
||||
|
||||
test('aligns ambiguous hops to the closest continuous path', () {
|
||||
final start = _node(
|
||||
name: 'Start',
|
||||
publicKey: 'start00',
|
||||
latitude: 46.000,
|
||||
longitude: 14.000,
|
||||
);
|
||||
final end = _node(
|
||||
name: 'End',
|
||||
publicKey: 'end000',
|
||||
latitude: 46.300,
|
||||
longitude: 14.300,
|
||||
);
|
||||
final hop1Near = _node(
|
||||
name: 'Hop 1 Near',
|
||||
publicKey: 'aa1100',
|
||||
latitude: 46.100,
|
||||
longitude: 14.100,
|
||||
);
|
||||
final hop1Far = _node(
|
||||
name: 'Hop 1 Far',
|
||||
publicKey: 'aa11ff',
|
||||
latitude: 46.250,
|
||||
longitude: 14.000,
|
||||
);
|
||||
final hop2Near = _node(
|
||||
name: 'Hop 2 Near',
|
||||
publicKey: 'bb2200',
|
||||
latitude: 46.200,
|
||||
longitude: 14.200,
|
||||
);
|
||||
final hop2Far = _node(
|
||||
name: 'Hop 2 Far',
|
||||
publicKey: 'bb22ff',
|
||||
latitude: 46.050,
|
||||
longitude: 14.280,
|
||||
);
|
||||
|
||||
final aligned = TraceNodeResolver.alignPathSelections(
|
||||
nodes: [
|
||||
TraceNodeResolver.resolveBest(
|
||||
nodes: [hop1Far, hop1Near],
|
||||
localPublicKeys: {hop1Near.publicKey, hop1Far.publicKey},
|
||||
prefixHex: 'aa11',
|
||||
),
|
||||
TraceNodeResolver.resolveBest(
|
||||
nodes: [hop2Far, hop2Near],
|
||||
localPublicKeys: {hop2Near.publicKey, hop2Far.publicKey},
|
||||
prefixHex: 'bb22',
|
||||
),
|
||||
],
|
||||
startNode: start,
|
||||
endNode: end,
|
||||
);
|
||||
|
||||
expect(aligned[0].node?.name, 'Hop 1 Near');
|
||||
expect(aligned[1].node?.name, 'Hop 2 Near');
|
||||
});
|
||||
}
|
||||
|
||||
MeshMapNode _node({
|
||||
required String name,
|
||||
required String publicKey,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) {
|
||||
return MeshMapNode(
|
||||
type: 1,
|
||||
name: name,
|
||||
publicKey: publicKey,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
updatedAtMs: 1,
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/map_provider.dart';
|
||||
@@ -14,6 +15,32 @@ import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakeAppProvider extends ChangeNotifier implements AppProvider {
|
||||
@override
|
||||
ChannelLocationSharingMode? channelLocationSharingModeForChannel(
|
||||
int channelIdx,
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChannelLocationSharingState> getChannelLocationSharingState(
|
||||
int channelIdx,
|
||||
) async {
|
||||
return const ChannelLocationSharingState(
|
||||
mode: ChannelLocationSharingMode.appFallback,
|
||||
isSharing: false,
|
||||
hardwareSupported: false,
|
||||
isConnected: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
@@ -41,22 +68,31 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpTile(
|
||||
Future<Future<void> Function()> pumpTile(
|
||||
WidgetTester tester,
|
||||
Contact contact, {
|
||||
SensorsProvider? sensorsProvider,
|
||||
}) async {
|
||||
final connectionProvider = ConnectionProvider();
|
||||
final contactsProvider = ContactsProvider();
|
||||
final messagesProvider = MessagesProvider();
|
||||
final mapProvider = MapProvider();
|
||||
final appProvider = _FakeAppProvider();
|
||||
final resolvedSensorsProvider = sensorsProvider ?? SensorsProvider();
|
||||
final ownsSensorsProvider = sensorsProvider == null;
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
||||
ChangeNotifierProvider(create: (_) => ContactsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
||||
ChangeNotifierProvider<ConnectionProvider>.value(
|
||||
value: connectionProvider,
|
||||
),
|
||||
ChangeNotifierProvider<ContactsProvider>.value(value: contactsProvider),
|
||||
ChangeNotifierProvider<MessagesProvider>.value(value: messagesProvider),
|
||||
ChangeNotifierProvider<SensorsProvider>.value(
|
||||
value: resolvedSensorsProvider,
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||
ChangeNotifierProvider<MapProvider>.value(value: mapProvider),
|
||||
ChangeNotifierProvider<AppProvider>.value(value: appProvider),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
@@ -65,64 +101,97 @@ void main() {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return () async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
connectionProvider.dispose();
|
||||
if (ownsSensorsProvider) {
|
||||
resolvedSensorsProvider.dispose();
|
||||
}
|
||||
await tester.pump();
|
||||
};
|
||||
}
|
||||
|
||||
testWidgets('shows trace action for non-channel contacts', (tester) async {
|
||||
await pumpTile(
|
||||
Future<void> withPumpedTile(
|
||||
WidgetTester tester,
|
||||
Contact contact,
|
||||
Future<void> Function() body, {
|
||||
SensorsProvider? sensorsProvider,
|
||||
}) async {
|
||||
final dispose = await pumpTile(
|
||||
tester,
|
||||
contact,
|
||||
sensorsProvider: sensorsProvider,
|
||||
);
|
||||
try {
|
||||
await body();
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('does not show diagnostic action for non-channel contacts', (
|
||||
tester,
|
||||
) async {
|
||||
await withPumpedTile(
|
||||
tester,
|
||||
buildContact(name: 'John Smith', type: ContactType.chat),
|
||||
);
|
||||
|
||||
() async {
|
||||
await tester.tap(find.text('John Smith'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Trace'), findsOneWidget);
|
||||
expect(find.text('Trace'), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('does not show trace action for channels', (tester) async {
|
||||
await pumpTile(
|
||||
testWidgets('does not show diagnostic action for channels', (tester) async {
|
||||
await withPumpedTile(
|
||||
tester,
|
||||
buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3),
|
||||
);
|
||||
|
||||
() async {
|
||||
await tester.tap(find.text('Ops'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Trace'), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows overridden contact name as primary label', (tester) async {
|
||||
await pumpTile(
|
||||
await withPumpedTile(
|
||||
tester,
|
||||
buildContact(
|
||||
name: 'John Smith',
|
||||
type: ContactType.chat,
|
||||
).copyWith(nameOverride: 'Rescue One'),
|
||||
);
|
||||
|
||||
() async {
|
||||
expect(find.text('Rescue One'), findsOneWidget);
|
||||
expect(find.text('John Smith'), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('hides public key in contact tile', (tester) async {
|
||||
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
|
||||
|
||||
await pumpTile(tester, contact);
|
||||
|
||||
await withPumpedTile(tester, contact, () async {
|
||||
expect(find.text(contact.publicKeyShort), findsNothing);
|
||||
expect(find.byIcon(Icons.key_outlined), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('sensor contacts can be added to sensors', (tester) async {
|
||||
await pumpTile(
|
||||
await withPumpedTile(
|
||||
tester,
|
||||
buildContact(name: 'WX Station', type: ContactType.sensor),
|
||||
);
|
||||
|
||||
() async {
|
||||
await tester.tap(find.text('WX Station'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add to Sensors'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('sensor preview shows telemetry card', (tester) async {
|
||||
@@ -146,8 +215,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await pumpTile(tester, contact);
|
||||
|
||||
await withPumpedTile(tester, contact, () async {
|
||||
await tester.tap(find.text('WX Station'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -170,7 +238,10 @@ void main() {
|
||||
expect(find.text('15 mA'), findsOneWidget);
|
||||
expect(find.text('Power'), findsOneWidget);
|
||||
expect(find.text('Distance'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('sensor_metric_battery')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('sensor_metric_battery')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('ch1'), findsWidgets);
|
||||
expect(
|
||||
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
|
||||
@@ -188,4 +259,5 @@ void main() {
|
||||
|
||||
expect(find.byType(SensorTelemetryCard), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
37
test/widgets/sensor_history_sheet_test.dart
Normal file
37
test/widgets/sensor_history_sheet_test.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/widgets/sensors/sensor_history_sheet.dart';
|
||||
|
||||
void main() {
|
||||
test('history sheet honors initial field key when available', () {
|
||||
final selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: 'extra:illuminance_2',
|
||||
availableFieldKeys: const <String>[
|
||||
'temperature',
|
||||
'extra:illuminance_2',
|
||||
],
|
||||
);
|
||||
|
||||
expect(selectedFieldKey, 'extra:illuminance_2');
|
||||
});
|
||||
|
||||
test('history sheet falls back to first available field', () {
|
||||
final selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: 'extra:missing',
|
||||
availableFieldKeys: const <String>[
|
||||
'temperature',
|
||||
'extra:illuminance_2',
|
||||
],
|
||||
);
|
||||
|
||||
expect(selectedFieldKey, 'temperature');
|
||||
});
|
||||
|
||||
test('history sheet returns null when no fields are available', () {
|
||||
final selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: 'temperature',
|
||||
availableFieldKeys: const <String>[],
|
||||
);
|
||||
|
||||
expect(selectedFieldKey, isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -517,7 +518,69 @@ void main() {
|
||||
expect(moveDownCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('overflow menu copies raw response', (tester) async {
|
||||
testWidgets('tapping a measurement tile triggers metric callback', (
|
||||
tester,
|
||||
) async {
|
||||
final contact = buildContact();
|
||||
String? tappedFieldKey;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SensorTelemetryCard(
|
||||
contact: contact,
|
||||
state: SensorRefreshState.idle,
|
||||
visibleFields: const {'temperature'},
|
||||
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
|
||||
onMetricTap: (fieldKey) async {
|
||||
tappedFieldKey = fieldKey;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('sensor_metric_temperature')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tappedFieldKey, 'temperature');
|
||||
});
|
||||
|
||||
testWidgets('tapping the card body does not trigger metric callback', (
|
||||
tester,
|
||||
) async {
|
||||
final contact = buildContact();
|
||||
var tapCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SensorTelemetryCard(
|
||||
contact: contact,
|
||||
state: SensorRefreshState.idle,
|
||||
visibleFields: const {'temperature'},
|
||||
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
|
||||
onMetricTap: (_) async {
|
||||
tapCount += 1;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('WX Station'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapCount, 0);
|
||||
});
|
||||
|
||||
testWidgets('raw response metadata alone does not show an overflow menu', (
|
||||
tester,
|
||||
) async {
|
||||
final publicKey = Uint8List(32);
|
||||
publicKey[0] = 0x48;
|
||||
final contact = Contact(
|
||||
@@ -538,11 +601,8 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
scaffoldMessengerKey: scaffoldKey,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
@@ -556,44 +616,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('Copy raw response'), findsOneWidget);
|
||||
|
||||
// Capture clipboard writes via the test platform channel mock.
|
||||
String? clipboardText;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(MethodCall call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
clipboardText = args['text'] as String?;
|
||||
}
|
||||
if (call.method == 'Clipboard.getData') {
|
||||
return <String, dynamic>{'text': clipboardText};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(() {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
await tester.tap(find.text('Copy raw response'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(clipboardText, '01 67 00 d7');
|
||||
expect(find.text('Raw response copied'), findsOneWidget);
|
||||
|
||||
// Clear the SnackBar to prevent its timer from blocking teardown.
|
||||
scaffoldKey.currentState?.clearSnackBars();
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
expect(find.byIcon(Icons.more_vert), findsNothing);
|
||||
expect(find.byIcon(Icons.chevron_right_rounded), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user