diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 873f80c..d0fe214 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 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; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index b775490..17c0aa5 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 136 + 137 LSRequiresIPhoneOS ITSAppUsesNonExemptEncryption diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index bd8342f..fc05a60 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 46b0024..83d611f 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -12,6 +12,30 @@ import 'contacts_provider.dart'; enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } +class SensorHistorySample { + final DateTime timestamp; + final Map values; + + const SensorHistorySample({required this.timestamp, required this.values}); + + factory SensorHistorySample.fromJson(Map json) { + final rawValues = json['values'] as Map? ?? const {}; + return SensorHistorySample( + timestamp: DateTime.fromMillisecondsSinceEpoch( + (json['timestampMillis'] as num?)?.toInt() ?? 0, + ), + values: rawValues.map( + (key, value) => MapEntry(key, (value as num).toDouble()), + ), + ); + } + + Map 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 supportedAutoRefreshIntervals = [ 0, 5, @@ -62,6 +90,8 @@ class SensorsProvider with ChangeNotifier { >{}; final Map _autoRefreshMinutesBySensor = {}; final Map _lastRefreshAttemptAt = {}; + final Map> _historyBySensor = + >{}; 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; 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; + for (final entry in decoded.entries) { + final rawSamples = entry.value as List? ?? const []; + final samples = rawSamples + .whereType>() + .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 _persistHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + final encoded = {}; + 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 visibleFieldsFor(String publicKeyHex) => Set.unmodifiable( _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields, ); @@ -312,6 +377,11 @@ class SensorsProvider with ChangeNotifier { int autoRefreshMinutesFor(String publicKeyHex) => _autoRefreshMinutesBySensor[publicKeyHex] ?? 0; + List historyFor(String publicKeyHex) => + List.unmodifiable( + _historyBySensor[publicKeyHex] ?? const [], + ); + Future 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 captureTrackedTelemetryHistory({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) async { + final trackedKeys = { + ..._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 = []; @@ -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, + () => [], + ); + 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 _historyValuesForTelemetry(ContactTelemetry telemetry) { + final values = {}; + + 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'; + } } diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 74c0d6c..ab96e2f 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -35,9 +35,7 @@ class _DiscoveryScreenState extends State { @override void initState() { super.initState(); - _cachedNodesFuture = MeshMapNodesService.loadCachedNodes( - cacheTtl: MeshMapNodesService.traceCacheTtl, - ); + _cachedNodesFuture = MeshMapNodesService.loadCachedNodes(); if (widget.autoDiscoverRepeatersOnOpen) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index afae545..dffa745 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 bool _isSensorsEnabled = false; AppLifecycleState _lifecycleState = AppLifecycleState.resumed; String? _lastProfileDeviceKey; + Timer? _sensorAutoRefreshTicker; List<_HomeTab> get _enabledTabs { return [ @@ -116,6 +118,7 @@ class _HomeScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _handleConnectionProviderChanged(); }); + _configureSensorAutoRefreshTicker(); } void _initTabController() { @@ -182,6 +185,7 @@ class _HomeScreenState extends State if (!_isMapEnabled) { _isMapFullscreen = false; } + _configureSensorAutoRefreshTicker(); final newTabs = _enabledTabs; final newIndex = newTabs.indexOf(oldTab); @@ -252,6 +256,7 @@ class _HomeScreenState extends State @override void dispose() { + _sensorAutoRefreshTicker?.cancel(); _connectionProvider.removeListener(_handleConnectionProviderChanged); WidgetsBinding.instance.removeObserver(this); _appProvider.setFastLocationUiActive(false); @@ -269,11 +274,50 @@ class _HomeScreenState extends State 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 _runSensorAutoRefreshTick() async { + if (!mounted || + !_isSensorsEnabled || + _lifecycleState != AppLifecycleState.resumed) { + return; + } + + final sensorsProvider = context.read(); + final contactsProvider = context.read(); + final connectionProvider = context.read(); + sensorsProvider.clearExpiredRefreshStates(); + await sensorsProvider.refreshDueSensors( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + now: DateTime.now(), + ); + await sensorsProvider.captureTrackedTelemetryHistory( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + } + Future _loadRxTxPreference() async { final prefs = await SharedPreferences.getInstance(); if (mounted) { diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart index be0ca89..ee2ce1b 100644 --- a/lib/screens/live_traffic_screen.dart +++ b/lib/screens/live_traffic_screen.dart @@ -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 _showTraceSheet( - BuildContext context, - LiveTrafficEntry entry, - ) { - final route = entry.route; - if (route == null || route.pathBytes.isEmpty) { - return Future.value(); - } - return showModalBottomSheet( - 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 { diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index 65b737f..c331ed2 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -750,9 +750,7 @@ class _DecodedRouteSection extends StatelessWidget { return FutureBuilder>( future: Future.wait([ RouteHashPreferences.getHashSize(), - MeshMapNodesService.loadCachedNodes( - cacheTtl: MeshMapNodesService.traceCacheTtl, - ), + MeshMapNodesService.loadCachedNodes(), ]), builder: (context, snapshot) { final decodedRoute = LogRxRouteDecoder.decode( diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index e2db813..16eeb23 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -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 { - static const Duration _autoRefreshTickInterval = Duration(seconds: 30); - Timer? _minuteTicker; final Map _lastCenteredTelemetryAtBySensor = {}; @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 _handleMinuteTick() async { - if (!mounted || !widget.isActive) { - return; - } - - final sensorsProvider = context.read(); - sensorsProvider.clearExpiredRefreshStates(); - await sensorsProvider.refreshDueSensors( - contactsProvider: context.read(), - connectionProvider: context.read(), - now: DateTime.now(), - ); - if (!mounted) { - return; - } - setState(() {}); - } - Future _showAddSensorSheet(BuildContext context) async { final sensorsProvider = context.read(); final contactsProvider = context.read(); @@ -356,6 +302,20 @@ class _SensorsTabState extends State { : 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 diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 042765c..3705d95 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { 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 { _loadFastLocationSettings(); _loadDeveloperMode(); _loadProfilesEnabled(); - _loadOnlineTraceCacheStatus(); _loadMapPreferences(); _loadNotificationPreferences(); _loadMessageDestinationLock(); @@ -185,14 +181,6 @@ class _SettingsScreenState extends State { }); } - Future _loadOnlineTraceCacheStatus() async { - final cachedAt = await MeshMapNodesService.cachedAt(); - if (!mounted) return; - setState(() { - _onlineTraceCacheUpdatedAt = cachedAt; - }); - } - Future _loadMessageDestinationLock() async { final lockedDestination = await MessageDestinationPreferences.getLockedDestination(); @@ -1119,66 +1107,6 @@ class _SettingsScreenState extends State { ); } - Future _clearOnlineTraceCache() async { - final confirmed = await showDialog( - 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 _showRouteHashSizeDialog() async { final selected = await showDialog( context: context, @@ -1614,11 +1542,10 @@ class _SettingsScreenState extends State { ]), 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 { 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), diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart index a34c89c..ffc027d 100644 --- a/lib/services/mesh_map_nodes_service.dart +++ b/lib/services/mesh_map_nodes_service.dart @@ -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? _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}'); } diff --git a/lib/utils/trace_node_resolver.dart b/lib/utils/trace_node_resolver.dart deleted file mode 100644 index be9d22a..0000000 --- a/lib/utils/trace_node_resolver.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'package:latlong2/latlong.dart'; - -import '../services/mesh_map_nodes_service.dart'; - -class ResolvedTraceNode { - final List 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 nodes, - required Set localPublicKeys, - required String? prefixHex, - LatLng? referenceA, - LatLng? referenceB, - String? preferredPrefix, - }) { - if (prefixHex == null || prefixHex.isEmpty) { - return const ResolvedTraceNode( - candidates: [], - matchCount: 0, - usedOnlineFallback: false, - ); - } - - final allMatches = nodes - .where((n) => n.publicKey.startsWith(prefixHex)) - .toList(); - if (allMatches.isEmpty) { - return const ResolvedTraceNode( - candidates: [], - 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.unmodifiable(pool), - matchCount: pool.length, - usedOnlineFallback: usedOnlineFallback, - ); - } - - static List alignPathSelections({ - required List nodes, - MeshMapNode? startNode, - MeshMapNode? endNode, - }) { - if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) { - return nodes; - } - - final candidateCosts = List.generate( - nodes.length, - (_) => [], - growable: false, - ); - final previousChoice = List.generate( - nodes.length, - (_) => [], - growable: false, - ); - - for (var i = 0; i < nodes.length; i++) { - final currentCandidates = nodes[i].candidates; - candidateCosts[i] = List.filled( - currentCandidates.length, - double.infinity, - ); - previousChoice[i] = List.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.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.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); - } -} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 0954648..fab6248 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -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, diff --git a/lib/widgets/contacts/contact_trace_sheet.dart b/lib/widgets/contacts/contact_trace_sheet.dart deleted file mode 100644 index 71e5cd0..0000000 --- a/lib/widgets/contacts/contact_trace_sheet.dart +++ /dev/null @@ -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 createState() => _ContactTraceSheetState(); -} - -class _ContactTraceSheetState extends State { - late final Future<_ContactTraceResult> _future; - _ContactTraceResult? _traceOverride; - - @override - void initState() { - super.initState(); - _future = _loadTrace(); - } - - Future<_ContactTraceResult> _loadTrace() async { - final connectionProvider = context.read(); - final contactsProvider = context.read(); - 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 nodes, - required Set localPublicKeys, - required List? 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 []; - - 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? 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 _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() - .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 _mergeNodes( - List preferred, - List fallback, - ) { - final merged = {}; - 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 routeHashes; - final List 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); -} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 3fe87d6..b3b20f9 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -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 { _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 { ); } - 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(); diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart deleted file mode 100644 index 8ec970c..0000000 --- a/lib/widgets/messages/message_trace_sheet.dart +++ /dev/null @@ -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? 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 packetPath, - this.descriptionOverride, - this.noRelayMatchTextOverride, - }) : message = null, - packetPathOverride = packetPath; - - @override - State createState() => _MessageTraceSheetState(); -} - -class _MessageTraceSheetState extends State { - late final Future<_TraceResult> _future; - _TraceResult? _traceOverride; - - @override - void initState() { - super.initState(); - _future = _loadTrace(); - } - - Future<_TraceResult> _loadTrace() async { - final connectionProvider = context.read(); - final contactsProvider = context.read(); - final messagesProvider = context.read(); - final preferredHashSize = await RouteHashPreferences.getHashSize(); - List? 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? 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 _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() - .where((node) => node.hasValidCoordinates) - .toList(); - } - - List _mergeNodes( - List preferred, - List fallback, - ) { - final merged = {}; - 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 nodes, - required Set localPublicKeys, - required List? 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 = [ - 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() - .length; - return concreteCount >= expectedRelayCount + 2; - } - - List? _extractPathFromPacketLogs({ - required List 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 _matchNodesFromPathHashes({ - required List nodes, - required Set localPublicKeys, - required List pathHashes, - required String? senderPrefix, - required String? recipientPrefix, - required LatLng? senderLatLng, - required LatLng? recipientLatLng, - }) { - final result = []; - 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 _inferRelaysFromHopCount({ - required List 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 pathHashes; - final List 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); -} diff --git a/lib/widgets/sensors/sensor_history_sheet.dart b/lib/widgets/sensors/sensor_history_sheet.dart new file mode 100644 index 0000000..031b4ec --- /dev/null +++ b/lib/widgets/sensors/sensor_history_sheet.dart @@ -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 showSensorHistorySheet( + BuildContext context, { + required String publicKeyHex, + String? initialFieldKey, +}) { + return showModalBottomSheet( + 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( + builder: + ( + context, + sensorsProvider, + contactsProvider, + connectionProvider, + child, + ) { + final contact = sensorsProvider.contactForDisplay( + widget.publicKeyHex, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final history = sensorsProvider.historyFor(widget.publicKeyHex); + final options = sensorMetricOptionsFor( + contact, + labelOverrides: sensorsProvider.labelOverridesFor( + widget.publicKeyHex, + ), + ); + final optionByKey = { + for (final option in options) option.key: option, + }; + final availableFieldKeys = { + for (final sample in history) ...sample.values.keys, + }.toList() + ..sort((a, b) { + final aIndex = options.indexWhere( + (option) => option.key == a, + ); + final bIndex = options.indexWhere( + (option) => option.key == b, + ); + if (aIndex == -1 && bIndex == -1) { + return a.compareTo(b); + } + if (aIndex == -1) { + return 1; + } + if (bIndex == -1) { + return -1; + } + return aIndex.compareTo(bIndex); + }); + + _selectedFieldKey = resolveInitialSensorHistoryField( + requestedFieldKey: _selectedFieldKey, + availableFieldKeys: availableFieldKeys, + ); + + final selectedFieldKey = _selectedFieldKey; + final selectedSamples = selectedFieldKey == null + ? const [] + : history + .where( + (sample) => + sample.values.containsKey(selectedFieldKey), + ) + .toList(growable: false); + final selectedOption = selectedFieldKey == null + ? null + : optionByKey[selectedFieldKey]; + final selectedCardData = selectedOption?.previewCardData; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + 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 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 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 history; + final String selectedFieldKey; + final Map optionByKey; + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemCount: history.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final sample = history[index]; + final selectedValue = sample.values[selectedFieldKey]; + final selectedOption = optionByKey[selectedFieldKey]; + + final secondaryMetrics = sample.values.entries + .where((entry) => entry.key != selectedFieldKey) + .take(3) + .map((entry) { + final option = optionByKey[entry.key]; + final cardData = option?.previewCardData; + return TextSpan( + text: + '${option?.defaultLabel ?? entry.key} ${_formatHistoryValue(cardData, entry.value)} ', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: cardData?.accent ?? Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ); + }) + .toList(growable: false); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatSampleTimestamp(sample.timestamp), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + '${selectedOption?.defaultLabel ?? selectedFieldKey} ${selectedValue == null ? '--' : _formatHistoryValue(selectedOption?.previewCardData, selectedValue)}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: + selectedOption?.previewCardData?.accent ?? + Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + if (secondaryMetrics.isNotEmpty) ...[ + const SizedBox(height: 4), + RichText( + text: TextSpan(children: secondaryMetrics), + ), + ], + ], + ), + ); + }, + ); + } +} + +LineChartData _historyLineChartData( + BuildContext context, { + required List samples, + 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'; +} diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index b8a9546..22503f1 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -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 Function()? onPing; final VoidCallback? onCustomize; final Future Function(Contact contact)? onShowMetHistory; + final Future Function(String fieldKey)? onMetricTap; final Future Function()? onMoveUp; final Future 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 _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 { 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'; } diff --git a/pubspec.yaml b/pubspec.yaml index 04c022c..5ca1816 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0408.1+51 +version: 2026.0410.1+52 environment: sdk: ^3.9.2 diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart index 226d7a5..70ebf33 100644 --- a/test/providers/sensors_provider_test.dart +++ b/test/providers/sensors_provider_test.dart @@ -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 = [ + 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(); diff --git a/test/services/mesh_map_nodes_service_test.dart b/test/services/mesh_map_nodes_service_test.dart index 1b69359..14c40f7 100644 --- a/test/services/mesh_map_nodes_service_test.dart +++ b/test/services/mesh_map_nodes_service_test.dart @@ -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({ diff --git a/test/utils/trace_node_resolver_test.dart b/test/utils/trace_node_resolver_test.dart deleted file mode 100644 index ec58feb..0000000 --- a/test/utils/trace_node_resolver_test.dart +++ /dev/null @@ -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, - ); -} diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart index 5118c4e..626a178 100644 --- a/test/widgets/contact_tile_test.dart +++ b/test/widgets/contact_tile_test.dart @@ -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 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 pumpTile( + Future 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.value( + value: connectionProvider, + ), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), ChangeNotifierProvider.value( value: resolvedSensorsProvider, ), - ChangeNotifierProvider(create: (_) => MapProvider()), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.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 withPumpedTile( + WidgetTester tester, + Contact contact, + Future 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); + }); }); } diff --git a/test/widgets/sensor_history_sheet_test.dart b/test/widgets/sensor_history_sheet_test.dart new file mode 100644 index 0000000..7f96627 --- /dev/null +++ b/test/widgets/sensor_history_sheet_test.dart @@ -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 [ + '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 [ + 'temperature', + 'extra:illuminance_2', + ], + ); + + expect(selectedFieldKey, 'temperature'); + }); + + test('history sheet returns null when no fields are available', () { + final selectedFieldKey = resolveInitialSensorHistoryField( + requestedFieldKey: 'temperature', + availableFieldKeys: const [], + ); + + expect(selectedFieldKey, isNull); + }); +} diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart index 9c9f2a6..eb58a30 100644 --- a/test/widgets/sensor_telemetry_card_test.dart +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -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(); - 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; - clipboardText = args['text'] as String?; - } - if (call.method == 'Clipboard.getData') { - return {'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); }); }