feat: Open per-metric sensor history

This commit is contained in:
Janez T
2026-04-12 21:07:53 +02:00
parent 7245d76e17
commit b333b3737e
25 changed files with 1248 additions and 2025 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';
}
}

View File

@@ -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((_) {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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

View File

@@ -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),

View File

@@ -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}');
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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>();

View File

@@ -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);
}

View 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';
}

View File

@@ -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,11 +1321,14 @@ class SensorTelemetryCard extends StatelessWidget {
),
),
if (_showsMenu)
Icon(
showActionSheetOnTap
? Icons.chevron_right_rounded
: Icons.more_horiz,
color: colorScheme.onSurfaceVariant,
IconButton(
onPressed: () => _showActionSheet(context),
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,
),
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
visualDensity: VisualDensity.compact,
),
],
),
@@ -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,15 +1382,7 @@ class SensorTelemetryCard extends StatelessWidget {
),
);
if (!_showsMenu || !showActionSheetOnTap) {
return card;
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showActionSheet(context),
child: card,
);
return card;
}
List<SensorMetricCardData> _buildMetricCards(
@@ -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';
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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({

View File

@@ -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,
);
}

View File

@@ -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'), findsNothing);
},
);
await tester.tap(find.text('John Smith'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsOneWidget);
});
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);
},
);
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);
},
);
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);
expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), findsNothing);
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);
},
);
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,46 +215,49 @@ void main() {
),
);
await pumpTile(tester, contact);
await withPumpedTile(tester, contact, () async {
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Preview'), findsOneWidget);
expect(find.text('Preview'), findsOneWidget);
await tester.tap(find.text('Preview'));
await tester.pumpAndSettle();
await tester.tap(find.text('Preview'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.text('Battery'), findsOneWidget);
expect(find.text('84%'), findsOneWidget);
expect(find.text('Temperature'), findsOneWidget);
expect(find.text('21.5°C'), findsOneWidget);
expect(find.text('CO2'), findsOneWidget);
expect(find.text('415 ppm'), findsOneWidget);
expect(find.text('Illuminance'), findsOneWidget);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(find.text('Current'), findsOneWidget);
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.text('ch1'), findsWidgets);
expect(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget,
);
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.text('Battery'), findsOneWidget);
expect(find.text('84%'), findsOneWidget);
expect(find.text('Temperature'), findsOneWidget);
expect(find.text('21.5°C'), findsOneWidget);
expect(find.text('CO2'), findsOneWidget);
expect(find.text('415 ppm'), findsOneWidget);
expect(find.text('Illuminance'), findsOneWidget);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(find.text('Current'), findsOneWidget);
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.text('ch1'), findsWidgets);
expect(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget,
);
final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard));
final batteryTileSize = tester.getSize(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard));
final batteryTileSize = tester.getSize(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.byType(SensorTelemetryCard), findsNothing);
expect(find.byType(SensorTelemetryCard), findsNothing);
});
});
}

View 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);
});
}

View File

@@ -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);
});
}