From e4eb40cd34ae039343f377926a0c64ffa0c52b8e Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 3 Apr 2026 11:38:57 +0200 Subject: [PATCH] fix: Hide Cleared Paths, Unify Sensor Actions #0 --- lib/screens/sensors_tab.dart | 26 + lib/services/path_history_service.dart | 93 +++- .../contacts/contact_route_dialog.dart | 4 +- lib/widgets/contacts/contact_tile.dart | 8 + .../messages/message_bubble_header.dart | 65 ++- .../sensors/sensor_telemetry_card.dart | 447 ++++++++++++++---- test/services/path_history_service_test.dart | 41 ++ 7 files changed, 551 insertions(+), 133 deletions(-) diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 506e0f2..f95932c 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -11,6 +11,7 @@ import '../providers/sensors_provider.dart'; import '../widgets/sensors/bthome_met_history_sheet.dart'; import '../widgets/sensors/sensor_telemetry_card.dart'; import '../l10n/app_localizations.dart'; +import '../utils/toast_logger.dart'; class SensorsTab extends StatefulWidget { final bool isActive; @@ -337,6 +338,7 @@ class _SensorsTabState extends State { child: SensorTelemetryCard( contact: contact, state: sensorsProvider.stateFor(key), + showActionSheetOnTap: true, visibleFields: visibleFields, fieldOrder: sensorsProvider.metricOrderFor( key, @@ -369,6 +371,30 @@ class _SensorsTabState extends State { contactsProvider: contactsProvider, connectionProvider: connectionProvider, ), + onPing: contact == null + ? null + : () async { + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: contact.routeHasPath, + onRetryWithFlooding: () { + if (context.mounted) { + ToastLogger.warning( + context, + AppLocalizations.of(context)! + .directPingTimeout(contact.displayName), + ); + } + }, + ); + if (context.mounted && !result.success) { + ToastLogger.error( + context, + AppLocalizations.of(context)! + .pingFailed(contact.displayName), + ); + } + }, ), ), ); diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index a492793..a81cfaf 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -11,34 +11,59 @@ import '../utils/log_rx_route_decoder.dart'; class PathHistoryService { static const String _storageKey = 'contact_path_history_v2'; + static const String _suppressedRouteStorageKey = + 'contact_path_history_suppressed_routes_v1'; static const int _maxDirectPaths = 20; static const int _topRotationCount = 3; final Map _cache = {}; + final Map _suppressedCurrentRoutes = {}; bool _isLoaded = false; Future initialize() async { if (_isLoaded) return; final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_storageKey); + final suppressedRaw = prefs.getString(_suppressedRouteStorageKey); if (raw == null || raw.isEmpty) { - _isLoaded = true; - return; + if (suppressedRaw == null || suppressedRaw.isEmpty) { + _isLoaded = true; + return; + } } try { - final decoded = jsonDecode(raw); - if (decoded is Map) { - for (final entry in decoded.entries) { - final value = entry.value; - if (value is Map) { - _cache[entry.key] = ContactPathHistory.fromJson(entry.key, value); + if (raw != null && raw.isNotEmpty) { + final decoded = jsonDecode(raw); + if (decoded is Map) { + for (final entry in decoded.entries) { + final value = entry.value; + if (value is Map) { + _cache[entry.key] = ContactPathHistory.fromJson(entry.key, value); + } } } } } catch (error) { debugPrint('⚠️ [PathHistoryService] Failed to load history: $error'); } + try { + if (suppressedRaw != null && suppressedRaw.isNotEmpty) { + final decoded = jsonDecode(suppressedRaw); + if (decoded is Map) { + for (final entry in decoded.entries) { + final value = entry.value; + if (value is String && value.isNotEmpty) { + _suppressedCurrentRoutes[entry.key] = value; + } + } + } + } + } catch (error) { + debugPrint( + '⚠️ [PathHistoryService] Failed to load suppressed routes: $error', + ); + } _isLoaded = true; } @@ -50,6 +75,9 @@ class PathHistoryService { final history = _historyFor(contact.publicKeyHex); final signature = _signature(contact.routePathBytes); + if (_suppressedCurrentRoutes[contact.publicKeyHex] == signature) { + return; + } final existing = _findDirectPath(history.directPaths, signature); final updated = PathRecord( pathBytes: contact.routePathBytes.toList(), @@ -100,6 +128,10 @@ class PathHistoryService { final signature = normalizedPathBytes .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); + _clearSuppressedRoute( + contactPublicKeyHex, + signature: signature, + ); final existing = _findDirectPath(history.directPaths, signature); final updated = PathRecord( pathBytes: normalizedPathBytes, @@ -209,6 +241,10 @@ class PathHistoryService { } final signature = _signature(selection.pathBytes); + _clearSuppressedRoute( + contactPublicKeyHex, + signature: signature, + ); final existing = _findDirectPath(history.directPaths, signature); final updated = PathRecord( pathBytes: selection.pathBytes.toList(), @@ -295,12 +331,21 @@ class PathHistoryService { Future clearHistoryFor(String contactPublicKeyHex) async { await initialize(); _cache.remove(contactPublicKeyHex); - final prefs = await SharedPreferences.getInstance(); - final payload = {}; - for (final entry in _cache.entries) { - payload[entry.key] = entry.value.toJson(); + _suppressedCurrentRoutes.remove(contactPublicKeyHex); + await _persistState(); + } + + Future clearHistoryForContact(Contact contact) async { + await initialize(); + _cache.remove(contact.publicKeyHex); + if (contact.routeHasPath && contact.routeHopCount > 0) { + _suppressedCurrentRoutes[contact.publicKeyHex] = _signature( + contact.routePathBytes, + ); + } else { + _suppressedCurrentRoutes.remove(contact.publicKeyHex); } - await prefs.setString(_storageKey, jsonEncode(payload)); + await _persistState(); } ContactPathHistory _historyFor(String contactPublicKeyHex) { @@ -315,12 +360,34 @@ class PathHistoryService { ContactPathHistory history, ) async { _cache[contactPublicKeyHex] = history; + await _persistState(); + } + + void _clearSuppressedRoute(String contactPublicKeyHex, {String? signature}) { + final suppressedSignature = _suppressedCurrentRoutes[contactPublicKeyHex]; + if (suppressedSignature == null) { + return; + } + if (signature == null || suppressedSignature == signature) { + _suppressedCurrentRoutes.remove(contactPublicKeyHex); + } + } + + Future _persistState() async { final prefs = await SharedPreferences.getInstance(); final payload = {}; for (final entry in _cache.entries) { payload[entry.key] = entry.value.toJson(); } + final suppressedPayload = {}; + for (final entry in _suppressedCurrentRoutes.entries) { + suppressedPayload[entry.key] = entry.value; + } await prefs.setString(_storageKey, jsonEncode(payload)); + await prefs.setString( + _suppressedRouteStorageKey, + jsonEncode(suppressedPayload), + ); } List _upsertDirectPath( diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index 7e261bf..256eacd 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -721,9 +721,7 @@ class _ContactRouteDialogState extends State { alignment: Alignment.centerRight, child: TextButton( onPressed: () async { - await _pathHistoryService.clearHistoryFor( - widget.contact.publicKeyHex, - ); + await _pathHistoryService.clearHistoryForContact(widget.contact); if (!mounted) return; setState(() { _pathHistory = _pathHistoryService.historyFor( diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index d73599f..06244fd 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart'; import '../../providers/sensors_provider.dart'; import '../../services/location_tracking_service.dart'; import '../../services/message_destination_preferences.dart'; +import '../../services/path_history_service.dart'; import 'contact_route_dialog.dart'; import 'contact_trace_sheet.dart'; import 'room_login_sheet.dart'; @@ -786,6 +787,7 @@ class ContactTile extends StatelessWidget { ) async { final contactsProvider = context.read(); final connectionProvider = context.read(); + final pathHistoryService = PathHistoryService(); final availableContacts = contactsProvider.contacts .where((candidate) => candidate.publicKeyHex != contact.publicKeyHex) .toList(); @@ -846,6 +848,12 @@ class ContactTile extends StatelessWidget { signedEncodedPathLen: parsedRoute.signedEncodedPathLen, paddedPathBytes: parsedRoute.paddedPathBytes, ); + await pathHistoryService.clearHistoryForContact( + contact.copyWith( + outPathLen: parsedRoute.signedEncodedPathLen, + outPath: Uint8List.fromList(parsedRoute.paddedPathBytes), + ), + ); if (context.mounted) { final routeLabel = parsedRoute.hopCount == 0 ? AppLocalizations.of(context)!.direct diff --git a/lib/widgets/messages/message_bubble_header.dart b/lib/widgets/messages/message_bubble_header.dart index 790e909..edf5f86 100644 --- a/lib/widgets/messages/message_bubble_header.dart +++ b/lib/widgets/messages/message_bubble_header.dart @@ -88,24 +88,26 @@ Widget buildBubbleMetaFooter( ).textTheme.labelSmall?.copyWith(color: metaColor), ), ]); - } else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) { - final effectivePathLen = _effectivePathLen(message, routeMetadata); - items.addAll([ - Icon(Icons.alt_route, size: 11, color: metaColor), - const SizedBox(width: 3), - Text( - effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: metaColor), - ), - Text( - ' • ', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: metaColor), - ), - ]); + } else if (!isSarMarker) { + final routeLabel = _effectiveRouteFooterLabel(message, routeMetadata); + if (routeLabel != null) { + items.addAll([ + Icon(Icons.alt_route, size: 11, color: metaColor), + const SizedBox(width: 3), + Text( + routeLabel, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + Text( + ' • ', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + ]); + } } items.add( @@ -127,8 +129,31 @@ Widget buildBubbleMetaFooter( ); } -int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) => - routeMetadata?.hopCount ?? message.pathLen; +String? _effectiveRouteFooterLabel( + Message message, + MessageRouteMetadata? routeMetadata, +) { + if (routeMetadata?.mode.name == 'flood') { + return 'flood'; + } + + final effectivePathLen = _effectivePathLen(message, routeMetadata); + if (effectivePathLen >= 255) { + return null; + } + + return effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop'; +} + +int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) { + if (routeMetadata?.hopCount != null) { + return routeMetadata!.hopCount!; + } + if (routeMetadata?.mode.name == 'flood') { + return 255; + } + return message.pathLen; +} Widget buildChannelHeaderPill( BuildContext context, { diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 0330364..1790096 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -1064,6 +1064,7 @@ class SensorTelemetryCard extends StatelessWidget { final Map fieldSpans; final Future Function()? onRemove; final Future Function()? onRefresh; + final Future Function()? onPing; final VoidCallback? onCustomize; final Future Function(Contact contact)? onShowMetHistory; final Future Function()? onMoveUp; @@ -1071,6 +1072,7 @@ class SensorTelemetryCard extends StatelessWidget { final EdgeInsetsGeometry margin; final String emptyMetricsMessage; final Map labelOverrides; + final bool showActionSheetOnTap; const SensorTelemetryCard({ super.key, @@ -1081,6 +1083,7 @@ class SensorTelemetryCard extends StatelessWidget { required this.fieldSpans, this.onRemove, this.onRefresh, + this.onPing, this.onCustomize, this.onShowMetHistory, this.onMoveUp, @@ -1089,6 +1092,7 @@ class SensorTelemetryCard extends StatelessWidget { this.emptyMetricsMessage = 'All fields are hidden. Use Visible fields to choose what to show.', this.labelOverrides = const {}, + this.showActionSheetOnTap = false, }); String _formatSpeed(num metersPerSecond) { @@ -1103,6 +1107,7 @@ class SensorTelemetryCard extends StatelessWidget { bool get _showsMenu => onRefresh != null || + onPing != null || onCustomize != null || onRemove != null || onMoveUp != null || @@ -1112,6 +1117,128 @@ class SensorTelemetryCard extends StatelessWidget { onShowMetHistory != null && supportsBTHomeMetHistory(contact)); + Future _handleAction(BuildContext context, String value) async { + if (value == 'refresh' && onRefresh != null) { + await onRefresh!(); + return; + } + if (value == 'ping' && onPing != null) { + await onPing!(); + return; + } + if (value == 'move_up' && onMoveUp != null) { + await onMoveUp!(); + return; + } + if (value == 'move_down' && onMoveDown != null) { + 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( + const SnackBar(content: Text('Raw response copied')), + ); + } + } + return; + } + if (value == 'remove' && onRemove != null) { + await onRemove!(); + return; + } + if (value == 'customize' && onCustomize != null) { + onCustomize!(); + return; + } + if (value == 'met_history' && + contact != null && + onShowMetHistory != null) { + await onShowMetHistory!(contact!); + } + } + + List<_SensorSheetAction> _buildSheetActions(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final actions = <_SensorSheetAction>[ + if (onRefresh != null) + _SensorSheetAction( + icon: Icons.refresh, + label: l10n.refresh, + onTap: () => _handleAction(context, 'refresh'), + ), + if (onPing != null) + _SensorSheetAction( + icon: Icons.network_ping, + label: 'Ping', + onTap: () => _handleAction(context, 'ping'), + ), + if (onMoveUp != null) + _SensorSheetAction( + icon: Icons.arrow_upward_rounded, + label: 'Move up', + onTap: () => _handleAction(context, 'move_up'), + ), + if (onMoveDown != null) + _SensorSheetAction( + icon: Icons.arrow_downward_rounded, + label: 'Move down', + onTap: () => _handleAction(context, 'move_down'), + ), + if (_rawTelemetryHex(contact?.telemetry) != null) + _SensorSheetAction( + icon: Icons.copy_all_outlined, + label: 'Copy raw response', + onTap: () => _handleAction(context, 'copy_raw'), + ), + if (onCustomize != null) + _SensorSheetAction( + icon: Icons.tune, + label: l10n.customizeFields, + onTap: () => _handleAction(context, 'customize'), + ), + if (contact != null && + onShowMetHistory != null && + supportsBTHomeMetHistory(contact)) + _SensorSheetAction( + icon: Icons.show_chart, + label: 'MET history', + onTap: () => _handleAction(context, 'met_history'), + ), + if (onRemove != null) + _SensorSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.remove, + destructive: true, + onTap: () => _handleAction(context, 'remove'), + ), + ]; + return actions; + } + + Future _showActionSheet(BuildContext context) async { + if (!_showsMenu) { + return; + } + final actions = _buildSheetActions(context); + if (actions.isEmpty) { + return; + } + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => _SensorActionSheet( + contact: contact, + actions: actions, + onClose: () => Navigator.pop(sheetContext), + ), + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -1124,7 +1251,7 @@ class SensorTelemetryCard extends StatelessWidget { _buildMetricCards(l10n, telemetry, contact!), ); - return Container( + final card = Container( margin: margin, decoration: BoxDecoration( borderRadius: BorderRadius.circular(28), @@ -1212,102 +1339,11 @@ class SensorTelemetryCard extends StatelessWidget { ), ), if (_showsMenu) - PopupMenuButton( - onSelected: (value) async { - if (value == 'refresh' && onRefresh != null) { - await onRefresh!(); - } else if (value == 'move_up' && onMoveUp != null) { - await onMoveUp!(); - } else if (value == 'move_down' && onMoveDown != null) { - await onMoveDown!(); - } else 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( - const SnackBar( - content: Text('Raw response copied'), - ), - ); - } - } - } else if (value == 'remove' && onRemove != null) { - await onRemove!(); - } else if (value == 'customize' && onCustomize != null) { - onCustomize!(); - } else if (value == 'met_history' && - contact != null && - onShowMetHistory != null) { - await onShowMetHistory!(contact!); - } - }, - itemBuilder: (context) { - final items = >[]; - if (onRefresh != null) { - items.add( - PopupMenuItem( - value: 'refresh', - child: Text(l10n.refresh), - ), - ); - } - if (onMoveUp != null) { - items.add( - const PopupMenuItem( - value: 'move_up', - child: Text('Move up'), - ), - ); - } - if (onMoveDown != null) { - items.add( - const PopupMenuItem( - value: 'move_down', - child: Text('Move down'), - ), - ); - } - if (_rawTelemetryHex(contact?.telemetry) != null) { - items.add( - const PopupMenuItem( - value: 'copy_raw', - child: Text('Copy raw response'), - ), - ); - } - if (onCustomize != null) { - items.add( - PopupMenuItem( - value: 'customize', - child: Text(l10n.customizeFields), - ), - ); - } - if (contact != null && - onShowMetHistory != null && - supportsBTHomeMetHistory(contact)) { - items.add( - const PopupMenuItem( - value: 'met_history', - child: Text('MET history'), - ), - ); - } - if (onRemove != null) { - items.add( - PopupMenuItem( - value: 'remove', - child: Text(l10n.remove), - ), - ); - } - return items; - }, + Icon( + showActionSheetOnTap + ? Icons.chevron_right_rounded + : Icons.more_horiz, + color: colorScheme.onSurfaceVariant, ), ], ), @@ -1355,6 +1391,16 @@ class SensorTelemetryCard extends StatelessWidget { ), ), ); + + if (!_showsMenu || !showActionSheetOnTap) { + return card; + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showActionSheet(context), + child: card, + ); } List _buildMetricCards( @@ -2826,6 +2872,213 @@ class SensorMetricCardData { }); } +class _SensorSheetAction { + final IconData icon; + final String label; + final Future Function() onTap; + final bool destructive; + + const _SensorSheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); +} + +class _SensorActionSheet extends StatelessWidget { + final Contact? contact; + final List<_SensorSheetAction> actions; + final VoidCallback onClose; + + const _SensorActionSheet({ + required this.contact, + required this.actions, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final bottomInset = MediaQuery.of(context).viewPadding.bottom; + final title = contact?.displayName ?? 'Sensor'; + final subtitle = contact == null ? 'Unavailable node' : contact!.publicKeyShort; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: Material( + color: colorScheme.surface, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: LinearGradient( + colors: [ + colorScheme.primaryContainer, + colorScheme.secondaryContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Icon( + Icons.sensors_rounded, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.45, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: contact == null ? null : 'monospace', + ), + ), + ], + ), + ), + ), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + ), + ], + ), + const SizedBox(height: 18), + ...actions.map( + (action) => _SensorActionTile( + action: action, + onClose: onClose, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SensorActionTile extends StatelessWidget { + final _SensorSheetAction action; + final VoidCallback onClose; + + const _SensorActionTile({ + required this.action, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final foreground = action.destructive + ? colorScheme.error + : colorScheme.onSurface; + final iconBackground = action.destructive + ? colorScheme.error.withValues(alpha: 0.12) + : colorScheme.surfaceContainerHigh; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: () async { + onClose(); + await action.onTap(); + }, + child: Ink( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.45), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconBackground, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(action.icon, color: foreground), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + action.label, + style: theme.textTheme.titleMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + Icon( + Icons.chevron_right_rounded, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + class _ParsedMetricKey { final String baseKey; final int? channel; diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 05d7df1..9b23e8c 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -259,6 +259,47 @@ void main() { expect(service.historyFor('def456').directPaths, hasLength(1)); }); + test( + 'clear history for contact suppresses immediate relearn of current route', + () async { + final service = PathHistoryService(); + final contact = _buildContact( + seed: 5, + pathBytes: [0xAA, 0xBB], + hopCount: 2, + hashSize: 1, + ); + + await service.initialize(); + await service.recordLearnedPath(contact); + expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1)); + + await service.clearHistoryForContact(contact); + expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty); + + await service.recordLearnedPath(contact); + expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty); + + await service.recordPathResult( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directHistorical, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + hopCount: 2, + hashSize: 1, + ), + success: true, + roundTripTimeMs: 120, + ); + + expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1)); + expect( + service.historyFor(contact.publicKeyHex).directPaths.single.source, + PathRecordSource.learned, + ); + }, + ); + test('last successful direct path is chosen by location fit', () async { final service = PathHistoryService(); final contact = _buildContact(