fix: Hide Cleared Paths, Unify Sensor Actions #0

This commit is contained in:
Janez T
2026-04-03 11:38:57 +02:00
parent 7c9dfd6f63
commit e4eb40cd34
7 changed files with 551 additions and 133 deletions

View File

@@ -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<SensorsTab> {
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(key),
showActionSheetOnTap: true,
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
key,
@@ -369,6 +371,30 @@ class _SensorsTabState extends State<SensorsTab> {
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),
);
}
},
),
),
);

View File

@@ -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<String, ContactPathHistory> _cache = {};
final Map<String, String> _suppressedCurrentRoutes = {};
bool _isLoaded = false;
Future<void> 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<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
if (raw != null && raw.isNotEmpty) {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_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<String, dynamic>) {
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<void> clearHistoryFor(String contactPublicKeyHex) async {
await initialize();
_cache.remove(contactPublicKeyHex);
final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{};
for (final entry in _cache.entries) {
payload[entry.key] = entry.value.toJson();
_suppressedCurrentRoutes.remove(contactPublicKeyHex);
await _persistState();
}
Future<void> 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<void> _persistState() async {
final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{};
for (final entry in _cache.entries) {
payload[entry.key] = entry.value.toJson();
}
final suppressedPayload = <String, dynamic>{};
for (final entry in _suppressedCurrentRoutes.entries) {
suppressedPayload[entry.key] = entry.value;
}
await prefs.setString(_storageKey, jsonEncode(payload));
await prefs.setString(
_suppressedRouteStorageKey,
jsonEncode(suppressedPayload),
);
}
List<PathRecord> _upsertDirectPath(

View File

@@ -721,9 +721,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
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(

View File

@@ -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<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>();
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

View File

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

View File

@@ -1064,6 +1064,7 @@ class SensorTelemetryCard extends StatelessWidget {
final Map<String, int> fieldSpans;
final Future<void> Function()? onRemove;
final Future<void> Function()? onRefresh;
final Future<void> Function()? onPing;
final VoidCallback? onCustomize;
final Future<void> Function(Contact contact)? onShowMetHistory;
final Future<void> Function()? onMoveUp;
@@ -1071,6 +1072,7 @@ class SensorTelemetryCard extends StatelessWidget {
final EdgeInsetsGeometry margin;
final String emptyMetricsMessage;
final Map<String, String> 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 <String, String>{},
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<void> _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<void> _showActionSheet(BuildContext context) async {
if (!_showsMenu) {
return;
}
final actions = _buildSheetActions(context);
if (actions.isEmpty) {
return;
}
await showModalBottomSheet<void>(
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<String>(
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 = <PopupMenuEntry<String>>[];
if (onRefresh != null) {
items.add(
PopupMenuItem<String>(
value: 'refresh',
child: Text(l10n.refresh),
),
);
}
if (onMoveUp != null) {
items.add(
const PopupMenuItem<String>(
value: 'move_up',
child: Text('Move up'),
),
);
}
if (onMoveDown != null) {
items.add(
const PopupMenuItem<String>(
value: 'move_down',
child: Text('Move down'),
),
);
}
if (_rawTelemetryHex(contact?.telemetry) != null) {
items.add(
const PopupMenuItem<String>(
value: 'copy_raw',
child: Text('Copy raw response'),
),
);
}
if (onCustomize != null) {
items.add(
PopupMenuItem<String>(
value: 'customize',
child: Text(l10n.customizeFields),
),
);
}
if (contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact)) {
items.add(
const PopupMenuItem<String>(
value: 'met_history',
child: Text('MET history'),
),
);
}
if (onRemove != null) {
items.add(
PopupMenuItem<String>(
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<SensorMetricCardData> _buildMetricCards(
@@ -2826,6 +2872,213 @@ class SensorMetricCardData {
});
}
class _SensorSheetAction {
final IconData icon;
final String label;
final Future<void> 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;