From 6af8b62d9b2c2fc036509e3a6fdd6fab84625621 Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 18 Mar 2026 16:38:19 +0100 Subject: [PATCH] fix: Show message signal chips by default #123 --- lib/widgets/messages/message_bubble.dart | 99 ++++++--- .../messages/message_bubble_signal.dart | 7 +- test/widgets/message_bubble_test.dart | 199 ++++++++++++++++++ 3 files changed, 265 insertions(+), 40 deletions(-) create mode 100644 test/widgets/message_bubble_test.dart diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index da8e201..42bff39 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -74,14 +74,12 @@ class MessageBubble extends StatefulWidget { class _MessageBubbleState extends State { static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]'); bool _isExpanded = false; - bool _showReceivedStats = false; @override void didUpdateWidget(MessageBubble oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.message.id != widget.message.id) { _isExpanded = false; - _showReceivedStats = false; return; } @@ -174,12 +172,6 @@ class _MessageBubbleState extends State { !widget.message.isSystemMessage) { context.read().markAsRead(widget.message.id); } - - if (!widget.isCompact && !isSarMarker && !isDrawing) { - setState(() { - _showReceivedStats = !_showReceivedStats; - }); - } widget.onTap?.call(); } @@ -684,7 +676,10 @@ class _MessageBubbleState extends State { void copyField(String value) { Clipboard.setData(ClipboardData(text: value)); - ToastLogger.success(context, AppLocalizations.of(context)!.textCopiedToClipboard); + ToastLogger.success( + context, + AppLocalizations.of(context)!.textCopiedToClipboard, + ); } showModalBottomSheet( @@ -812,7 +807,9 @@ class _MessageBubbleState extends State { ), _detailRow( sheetContext, - label: AppLocalizations.of(context)!.receivedRfc3339, + label: AppLocalizations.of( + context, + )!.receivedRfc3339, value: _formatRfc3339(widget.message.receivedAt), onCopy: () => copyField( _formatRfc3339(widget.message.receivedAt), @@ -821,14 +818,18 @@ class _MessageBubbleState extends State { if (widget.message.expectedAckTag != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.expectedAckTag, + label: AppLocalizations.of( + context, + )!.expectedAckTag, value: widget.message.expectedAckTag! .toString(), ), if (receptionDetails?.senderToReceiptMs != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.senderToReceipt, + label: AppLocalizations.of( + context, + )!.senderToReceipt, value: _formatDurationMs( receptionDetails!.senderToReceiptMs!, ), @@ -836,7 +837,9 @@ class _MessageBubbleState extends State { if (receptionDetails?.estimatedTransmitMs != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.estimatedTx, + label: AppLocalizations.of( + context, + )!.estimatedTx, value: _formatDurationMs( receptionDetails!.estimatedTransmitMs!, ), @@ -844,7 +847,9 @@ class _MessageBubbleState extends State { if (receptionDetails?.postTransmitDelayMs != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.posttxDelay, + label: AppLocalizations.of( + context, + )!.posttxDelay, value: _formatDurationMs( receptionDetails!.postTransmitDelayMs!, ), @@ -852,7 +857,9 @@ class _MessageBubbleState extends State { if (widget.receivedCopies > 1) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.receivedCopies, + label: AppLocalizations.of( + context, + )!.receivedCopies, value: '${widget.receivedCopies}', ), if (widget.message.suggestedTimeoutMs != null) @@ -883,7 +890,9 @@ class _MessageBubbleState extends State { if (widget.message.retryAttempt > 0) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.retryAttempt, + label: AppLocalizations.of( + context, + )!.retryAttempt, value: '${widget.message.retryAttempt}/4', ), if (widget.message.lastRetryAt != null) @@ -900,21 +909,27 @@ class _MessageBubbleState extends State { if (widget.message.usedFloodFallback) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.floodFallback, + label: AppLocalizations.of( + context, + )!.floodFallback, value: AppLocalizations.of(context)!.yes, ), if (routeMetadata?.canonicalPath case final routePath?) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.selectedPath, + label: AppLocalizations.of( + context, + )!.selectedPath, value: routePath, onCopy: () => copyField(routePath), ), if (retryResult != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.retryResult, + label: AppLocalizations.of( + context, + )!.retryResult, value: retryResult, ), if (packetPathHex != null) @@ -964,7 +979,9 @@ class _MessageBubbleState extends State { if (recipientPrefixHex != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.recipientKey, + label: AppLocalizations.of( + context, + )!.recipientKey, value: recipientPrefixHex, onCopy: () => copyField(recipientPrefixHex), ), @@ -994,7 +1011,9 @@ class _MessageBubbleState extends State { if (voiceSession != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.sessionProgress, + label: AppLocalizations.of( + context, + )!.sessionProgress, value: '${voiceSession.receivedCount}/${voiceSession.total} segments', ), @@ -1009,14 +1028,18 @@ class _MessageBubbleState extends State { if (transferDetails != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.transfers, + label: AppLocalizations.of( + context, + )!.transfers, value: '${transferDetails.totalTransfers}', ), if (transferDetails != null && transferDetails.downloaders.isNotEmpty) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.downloadedBy, + label: AppLocalizations.of( + context, + )!.downloadedBy, value: _formatDownloaderSummary( transferDetails, ), @@ -1024,7 +1047,9 @@ class _MessageBubbleState extends State { if (voiceTxEstimate > Duration.zero) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.estimatedTx, + label: AppLocalizations.of( + context, + )!.estimatedTx, value: voiceTxEstimate.inSeconds < 60 ? '~${voiceTxEstimate.inSeconds}s' : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', @@ -1075,14 +1100,18 @@ class _MessageBubbleState extends State { if (transferDetails != null) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.transfers, + label: AppLocalizations.of( + context, + )!.transfers, value: '${transferDetails.totalTransfers}', ), if (transferDetails != null && transferDetails.downloaders.isNotEmpty) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.downloadedBy, + label: AppLocalizations.of( + context, + )!.downloadedBy, value: _formatDownloaderSummary( transferDetails, ), @@ -1090,7 +1119,9 @@ class _MessageBubbleState extends State { if (imageTxEstimate > Duration.zero) _detailRow( sheetContext, - label: AppLocalizations.of(context)!.estimatedTx, + label: AppLocalizations.of( + context, + )!.estimatedTx, value: imageTxEstimate.inSeconds < 60 ? '~${imageTxEstimate.inSeconds}s' : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', @@ -1438,7 +1469,10 @@ class _MessageBubbleState extends State { // Share the location SharePlus.instance.share( - ShareParams(text: shareText, subject: AppLocalizations.of(context)!.sarLocationShare), + ShareParams( + text: shareText, + subject: AppLocalizations.of(context)!.sarLocationShare, + ), ); } @@ -2459,8 +2493,7 @@ class _MessageBubbleState extends State { if (!widget.isCompact && !isSarMarker && !message.isDrawing && - !message.isSentMessage && - _showReceivedStats) ...[ + !message.isSentMessage) ...[ const SizedBox(height: 6), buildReceivedSignalStatus( context, @@ -2673,7 +2706,6 @@ class _MessageBubbleState extends State { message.isContactMessage && message.deliveryStatus == MessageDeliveryStatus.delivered && - _showReceivedStats && message.roundTripTimeMs != null; return Column( @@ -2779,10 +2811,7 @@ class _MessageBubbleState extends State { ); }, ), - if (shouldShowSentChannelStats( - message, - showReceivedStats: _showReceivedStats, - )) ...[ + if (shouldShowSentChannelStats(message)) ...[ const SizedBox(height: 6), buildChannelEchoStatus(context, message), ], diff --git a/lib/widgets/messages/message_bubble_signal.dart b/lib/widgets/messages/message_bubble_signal.dart index 364de6c..5fe1b76 100644 --- a/lib/widgets/messages/message_bubble_signal.dart +++ b/lib/widgets/messages/message_bubble_signal.dart @@ -92,10 +92,7 @@ Widget buildChannelEchoStatus(BuildContext context, Message message) { ); } -bool shouldShowSentChannelStats( - Message message, { - required bool showReceivedStats, -}) { +bool shouldShowSentChannelStats(Message message) { if (!message.isSentMessage || !message.isChannelMessage) { return false; } @@ -105,7 +102,7 @@ bool shouldShowSentChannelStats( message.lastEchoRssiDbm != null || message.lastEchoSnrRaw != null || message.expectedAckTag != null; - return showReceivedStats && hasSignalData; + return hasSignalData; } Widget buildReceivedSignalStatus( diff --git a/test/widgets/message_bubble_test.dart b/test/widgets/message_bubble_test.dart new file mode 100644 index 0000000..6f7c57a --- /dev/null +++ b/test/widgets/message_bubble_test.dart @@ -0,0 +1,199 @@ +import 'dart:typed_data'; + +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/message.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_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/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/services/voice_codec_service.dart'; +import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('received bubbles show signal chips without tapping', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'received-signal', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(1), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Inbound message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.received, + lastEchoRssiDbm: -84, + lastEchoSnrRaw: 24, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('1 hop'), findsOneWidget); + expect(find.text('Fair'), findsOneWidget); + expect(find.text('-84'), findsOneWidget); + expect(find.text('6.0'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('delivered direct bubbles show timing chips without tapping', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-direct-signal', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(11), + recipientPublicKey: _key(77), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Outbound message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.delivered, + roundTripTimeMs: 320, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('Direct'), findsOneWidget); + expect(find.text('320ms'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('sent channel bubbles show echo chips without tapping', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-channel-signal', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(21), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Broadcast message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.sent, + echoCount: 2, + lastEchoRssiDbm: -76, + lastEchoSnrRaw: 20, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('x2'), findsOneWidget); + expect(find.text('-76'), findsOneWidget); + expect(find.text('5.0'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); +} + +Widget _buildApp(_TestHarness harness, Message message) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: harness.connectionProvider), + ChangeNotifierProvider.value(value: harness.contactsProvider), + ChangeNotifierProvider.value(value: harness.messagesProvider), + ChangeNotifierProvider.value(value: harness.drawingProvider), + ChangeNotifierProvider.value(value: harness.channelsProvider), + ChangeNotifierProvider.value(value: harness.voiceProvider), + ChangeNotifierProvider.value(value: harness.imageProvider), + ChangeNotifierProvider.value(value: harness.appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: MessageBubble(message: message)), + ), + ); +} + +class _TestHarness { + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final drawingProvider = DrawingProvider(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + + late final AppProvider appProvider; + + static Future<_TestHarness> create() async { + final harness = _TestHarness(); + await harness.messagesProvider.initialize(); + await harness.drawingProvider.initialize(); + harness.appProvider = AppProvider( + connectionProvider: harness.connectionProvider, + contactsProvider: harness.contactsProvider, + messagesProvider: harness.messagesProvider, + drawingProvider: harness.drawingProvider, + channelsProvider: harness.channelsProvider, + voiceProvider: harness.voiceProvider, + imageProvider: harness.imageProvider, + ); + return harness; + } + + bool _isDisposed = false; + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + } +} + +Uint8List _prefix(int seed) => + Uint8List.fromList(List.generate(6, (index) => seed + index)); + +Uint8List _key(int seed) => + Uint8List.fromList(List.generate(32, (index) => seed + index)); + +Future _disposeHarness(WidgetTester tester, _TestHarness harness) async { + await tester.pumpWidget(const SizedBox.shrink()); + harness.dispose(); + await tester.pump(); +}