mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
fix: Hide Cleared Paths, Unify Sensor Actions #0
This commit is contained in:
@@ -11,6 +11,7 @@ import '../providers/sensors_provider.dart';
|
|||||||
import '../widgets/sensors/bthome_met_history_sheet.dart';
|
import '../widgets/sensors/bthome_met_history_sheet.dart';
|
||||||
import '../widgets/sensors/sensor_telemetry_card.dart';
|
import '../widgets/sensors/sensor_telemetry_card.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/toast_logger.dart';
|
||||||
|
|
||||||
class SensorsTab extends StatefulWidget {
|
class SensorsTab extends StatefulWidget {
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
@@ -337,6 +338,7 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
child: SensorTelemetryCard(
|
child: SensorTelemetryCard(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
state: sensorsProvider.stateFor(key),
|
state: sensorsProvider.stateFor(key),
|
||||||
|
showActionSheetOnTap: true,
|
||||||
visibleFields: visibleFields,
|
visibleFields: visibleFields,
|
||||||
fieldOrder: sensorsProvider.metricOrderFor(
|
fieldOrder: sensorsProvider.metricOrderFor(
|
||||||
key,
|
key,
|
||||||
@@ -369,6 +371,30 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
connectionProvider: connectionProvider,
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,34 +11,59 @@ import '../utils/log_rx_route_decoder.dart';
|
|||||||
|
|
||||||
class PathHistoryService {
|
class PathHistoryService {
|
||||||
static const String _storageKey = 'contact_path_history_v2';
|
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 _maxDirectPaths = 20;
|
||||||
static const int _topRotationCount = 3;
|
static const int _topRotationCount = 3;
|
||||||
|
|
||||||
final Map<String, ContactPathHistory> _cache = {};
|
final Map<String, ContactPathHistory> _cache = {};
|
||||||
|
final Map<String, String> _suppressedCurrentRoutes = {};
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isLoaded) return;
|
if (_isLoaded) return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final raw = prefs.getString(_storageKey);
|
final raw = prefs.getString(_storageKey);
|
||||||
|
final suppressedRaw = prefs.getString(_suppressedRouteStorageKey);
|
||||||
if (raw == null || raw.isEmpty) {
|
if (raw == null || raw.isEmpty) {
|
||||||
_isLoaded = true;
|
if (suppressedRaw == null || suppressedRaw.isEmpty) {
|
||||||
return;
|
_isLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
if (raw != null && raw.isNotEmpty) {
|
||||||
if (decoded is Map<String, dynamic>) {
|
final decoded = jsonDecode(raw);
|
||||||
for (final entry in decoded.entries) {
|
if (decoded is Map<String, dynamic>) {
|
||||||
final value = entry.value;
|
for (final entry in decoded.entries) {
|
||||||
if (value is Map<String, dynamic>) {
|
final value = entry.value;
|
||||||
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
|
if (value is Map<String, dynamic>) {
|
||||||
|
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('⚠️ [PathHistoryService] Failed to load history: $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;
|
_isLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +75,9 @@ class PathHistoryService {
|
|||||||
|
|
||||||
final history = _historyFor(contact.publicKeyHex);
|
final history = _historyFor(contact.publicKeyHex);
|
||||||
final signature = _signature(contact.routePathBytes);
|
final signature = _signature(contact.routePathBytes);
|
||||||
|
if (_suppressedCurrentRoutes[contact.publicKeyHex] == signature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final existing = _findDirectPath(history.directPaths, signature);
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
final updated = PathRecord(
|
final updated = PathRecord(
|
||||||
pathBytes: contact.routePathBytes.toList(),
|
pathBytes: contact.routePathBytes.toList(),
|
||||||
@@ -100,6 +128,10 @@ class PathHistoryService {
|
|||||||
final signature = normalizedPathBytes
|
final signature = normalizedPathBytes
|
||||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
|
_clearSuppressedRoute(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
signature: signature,
|
||||||
|
);
|
||||||
final existing = _findDirectPath(history.directPaths, signature);
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
final updated = PathRecord(
|
final updated = PathRecord(
|
||||||
pathBytes: normalizedPathBytes,
|
pathBytes: normalizedPathBytes,
|
||||||
@@ -209,6 +241,10 @@ class PathHistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final signature = _signature(selection.pathBytes);
|
final signature = _signature(selection.pathBytes);
|
||||||
|
_clearSuppressedRoute(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
signature: signature,
|
||||||
|
);
|
||||||
final existing = _findDirectPath(history.directPaths, signature);
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
final updated = PathRecord(
|
final updated = PathRecord(
|
||||||
pathBytes: selection.pathBytes.toList(),
|
pathBytes: selection.pathBytes.toList(),
|
||||||
@@ -295,12 +331,21 @@ class PathHistoryService {
|
|||||||
Future<void> clearHistoryFor(String contactPublicKeyHex) async {
|
Future<void> clearHistoryFor(String contactPublicKeyHex) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
_cache.remove(contactPublicKeyHex);
|
_cache.remove(contactPublicKeyHex);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
_suppressedCurrentRoutes.remove(contactPublicKeyHex);
|
||||||
final payload = <String, dynamic>{};
|
await _persistState();
|
||||||
for (final entry in _cache.entries) {
|
}
|
||||||
payload[entry.key] = entry.value.toJson();
|
|
||||||
|
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) {
|
ContactPathHistory _historyFor(String contactPublicKeyHex) {
|
||||||
@@ -315,12 +360,34 @@ class PathHistoryService {
|
|||||||
ContactPathHistory history,
|
ContactPathHistory history,
|
||||||
) async {
|
) async {
|
||||||
_cache[contactPublicKeyHex] = history;
|
_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 prefs = await SharedPreferences.getInstance();
|
||||||
final payload = <String, dynamic>{};
|
final payload = <String, dynamic>{};
|
||||||
for (final entry in _cache.entries) {
|
for (final entry in _cache.entries) {
|
||||||
payload[entry.key] = entry.value.toJson();
|
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(_storageKey, jsonEncode(payload));
|
||||||
|
await prefs.setString(
|
||||||
|
_suppressedRouteStorageKey,
|
||||||
|
jsonEncode(suppressedPayload),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<PathRecord> _upsertDirectPath(
|
List<PathRecord> _upsertDirectPath(
|
||||||
|
|||||||
@@ -721,9 +721,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await _pathHistoryService.clearHistoryFor(
|
await _pathHistoryService.clearHistoryForContact(widget.contact);
|
||||||
widget.contact.publicKeyHex,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_pathHistory = _pathHistoryService.historyFor(
|
_pathHistory = _pathHistoryService.historyFor(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart';
|
|||||||
import '../../providers/sensors_provider.dart';
|
import '../../providers/sensors_provider.dart';
|
||||||
import '../../services/location_tracking_service.dart';
|
import '../../services/location_tracking_service.dart';
|
||||||
import '../../services/message_destination_preferences.dart';
|
import '../../services/message_destination_preferences.dart';
|
||||||
|
import '../../services/path_history_service.dart';
|
||||||
import 'contact_route_dialog.dart';
|
import 'contact_route_dialog.dart';
|
||||||
import 'contact_trace_sheet.dart';
|
import 'contact_trace_sheet.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
@@ -786,6 +787,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
) async {
|
) async {
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final pathHistoryService = PathHistoryService();
|
||||||
final availableContacts = contactsProvider.contacts
|
final availableContacts = contactsProvider.contacts
|
||||||
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
|
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -846,6 +848,12 @@ class ContactTile extends StatelessWidget {
|
|||||||
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||||
paddedPathBytes: parsedRoute.paddedPathBytes,
|
paddedPathBytes: parsedRoute.paddedPathBytes,
|
||||||
);
|
);
|
||||||
|
await pathHistoryService.clearHistoryForContact(
|
||||||
|
contact.copyWith(
|
||||||
|
outPathLen: parsedRoute.signedEncodedPathLen,
|
||||||
|
outPath: Uint8List.fromList(parsedRoute.paddedPathBytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final routeLabel = parsedRoute.hopCount == 0
|
final routeLabel = parsedRoute.hopCount == 0
|
||||||
? AppLocalizations.of(context)!.direct
|
? AppLocalizations.of(context)!.direct
|
||||||
|
|||||||
@@ -88,24 +88,26 @@ Widget buildBubbleMetaFooter(
|
|||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
} else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
|
} else if (!isSarMarker) {
|
||||||
final effectivePathLen = _effectivePathLen(message, routeMetadata);
|
final routeLabel = _effectiveRouteFooterLabel(message, routeMetadata);
|
||||||
items.addAll([
|
if (routeLabel != null) {
|
||||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
items.addAll([
|
||||||
const SizedBox(width: 3),
|
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||||
Text(
|
const SizedBox(width: 3),
|
||||||
effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
|
Text(
|
||||||
style: Theme.of(
|
routeLabel,
|
||||||
context,
|
style: Theme.of(
|
||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
context,
|
||||||
),
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
Text(
|
),
|
||||||
' • ',
|
Text(
|
||||||
style: Theme.of(
|
' • ',
|
||||||
context,
|
style: Theme.of(
|
||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
context,
|
||||||
),
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
]);
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
@@ -127,8 +129,31 @@ Widget buildBubbleMetaFooter(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
|
String? _effectiveRouteFooterLabel(
|
||||||
routeMetadata?.hopCount ?? message.pathLen;
|
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(
|
Widget buildChannelHeaderPill(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
|
|||||||
@@ -1064,6 +1064,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
final Map<String, int> fieldSpans;
|
final Map<String, int> fieldSpans;
|
||||||
final Future<void> Function()? onRemove;
|
final Future<void> Function()? onRemove;
|
||||||
final Future<void> Function()? onRefresh;
|
final Future<void> Function()? onRefresh;
|
||||||
|
final Future<void> Function()? onPing;
|
||||||
final VoidCallback? onCustomize;
|
final VoidCallback? onCustomize;
|
||||||
final Future<void> Function(Contact contact)? onShowMetHistory;
|
final Future<void> Function(Contact contact)? onShowMetHistory;
|
||||||
final Future<void> Function()? onMoveUp;
|
final Future<void> Function()? onMoveUp;
|
||||||
@@ -1071,6 +1072,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
final EdgeInsetsGeometry margin;
|
final EdgeInsetsGeometry margin;
|
||||||
final String emptyMetricsMessage;
|
final String emptyMetricsMessage;
|
||||||
final Map<String, String> labelOverrides;
|
final Map<String, String> labelOverrides;
|
||||||
|
final bool showActionSheetOnTap;
|
||||||
|
|
||||||
const SensorTelemetryCard({
|
const SensorTelemetryCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -1081,6 +1083,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
required this.fieldSpans,
|
required this.fieldSpans,
|
||||||
this.onRemove,
|
this.onRemove,
|
||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
|
this.onPing,
|
||||||
this.onCustomize,
|
this.onCustomize,
|
||||||
this.onShowMetHistory,
|
this.onShowMetHistory,
|
||||||
this.onMoveUp,
|
this.onMoveUp,
|
||||||
@@ -1089,6 +1092,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
this.emptyMetricsMessage =
|
this.emptyMetricsMessage =
|
||||||
'All fields are hidden. Use Visible fields to choose what to show.',
|
'All fields are hidden. Use Visible fields to choose what to show.',
|
||||||
this.labelOverrides = const <String, String>{},
|
this.labelOverrides = const <String, String>{},
|
||||||
|
this.showActionSheetOnTap = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
String _formatSpeed(num metersPerSecond) {
|
String _formatSpeed(num metersPerSecond) {
|
||||||
@@ -1103,6 +1107,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
|
|
||||||
bool get _showsMenu =>
|
bool get _showsMenu =>
|
||||||
onRefresh != null ||
|
onRefresh != null ||
|
||||||
|
onPing != null ||
|
||||||
onCustomize != null ||
|
onCustomize != null ||
|
||||||
onRemove != null ||
|
onRemove != null ||
|
||||||
onMoveUp != null ||
|
onMoveUp != null ||
|
||||||
@@ -1112,6 +1117,128 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
onShowMetHistory != null &&
|
onShowMetHistory != null &&
|
||||||
supportsBTHomeMetHistory(contact));
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -1124,7 +1251,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
_buildMetricCards(l10n, telemetry, contact!),
|
_buildMetricCards(l10n, telemetry, contact!),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
final card = Container(
|
||||||
margin: margin,
|
margin: margin,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
@@ -1212,102 +1339,11 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_showsMenu)
|
if (_showsMenu)
|
||||||
PopupMenuButton<String>(
|
Icon(
|
||||||
onSelected: (value) async {
|
showActionSheetOnTap
|
||||||
if (value == 'refresh' && onRefresh != null) {
|
? Icons.chevron_right_rounded
|
||||||
await onRefresh!();
|
: Icons.more_horiz,
|
||||||
} else if (value == 'move_up' && onMoveUp != null) {
|
color: colorScheme.onSurfaceVariant,
|
||||||
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;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -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(
|
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 {
|
class _ParsedMetricKey {
|
||||||
final String baseKey;
|
final String baseKey;
|
||||||
final int? channel;
|
final int? channel;
|
||||||
|
|||||||
@@ -259,6 +259,47 @@ void main() {
|
|||||||
expect(service.historyFor('def456').directPaths, hasLength(1));
|
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 {
|
test('last successful direct path is chosen by location fit', () async {
|
||||||
final service = PathHistoryService();
|
final service = PathHistoryService();
|
||||||
final contact = _buildContact(
|
final contact = _buildContact(
|
||||||
|
|||||||
Reference in New Issue
Block a user