fix: Show message signal chips by default #123

This commit is contained in:
Janez T
2026-03-18 16:38:19 +01:00
parent 01fbb7de6c
commit 6af8b62d9b
3 changed files with 265 additions and 40 deletions

View File

@@ -74,14 +74,12 @@ class MessageBubble extends StatefulWidget {
class _MessageBubbleState extends State<MessageBubble> { class _MessageBubbleState extends State<MessageBubble> {
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]'); static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false;
@override @override
void didUpdateWidget(MessageBubble oldWidget) { void didUpdateWidget(MessageBubble oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.message.id != widget.message.id) { if (oldWidget.message.id != widget.message.id) {
_isExpanded = false; _isExpanded = false;
_showReceivedStats = false;
return; return;
} }
@@ -174,12 +172,6 @@ class _MessageBubbleState extends State<MessageBubble> {
!widget.message.isSystemMessage) { !widget.message.isSystemMessage) {
context.read<MessagesProvider>().markAsRead(widget.message.id); context.read<MessagesProvider>().markAsRead(widget.message.id);
} }
if (!widget.isCompact && !isSarMarker && !isDrawing) {
setState(() {
_showReceivedStats = !_showReceivedStats;
});
}
widget.onTap?.call(); widget.onTap?.call();
} }
@@ -684,7 +676,10 @@ class _MessageBubbleState extends State<MessageBubble> {
void copyField(String value) { void copyField(String value) {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ToastLogger.success(context, AppLocalizations.of(context)!.textCopiedToClipboard); ToastLogger.success(
context,
AppLocalizations.of(context)!.textCopiedToClipboard,
);
} }
showModalBottomSheet( showModalBottomSheet(
@@ -812,7 +807,9 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.receivedRfc3339, label: AppLocalizations.of(
context,
)!.receivedRfc3339,
value: _formatRfc3339(widget.message.receivedAt), value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField( onCopy: () => copyField(
_formatRfc3339(widget.message.receivedAt), _formatRfc3339(widget.message.receivedAt),
@@ -821,14 +818,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.expectedAckTag != null) if (widget.message.expectedAckTag != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.expectedAckTag, label: AppLocalizations.of(
context,
)!.expectedAckTag,
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (receptionDetails?.senderToReceiptMs != null) if (receptionDetails?.senderToReceiptMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.senderToReceipt, label: AppLocalizations.of(
context,
)!.senderToReceipt,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!, receptionDetails!.senderToReceiptMs!,
), ),
@@ -836,7 +837,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.estimatedTransmitMs != null) if (receptionDetails?.estimatedTransmitMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.estimatedTx, label: AppLocalizations.of(
context,
)!.estimatedTx,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!, receptionDetails!.estimatedTransmitMs!,
), ),
@@ -844,7 +847,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.postTransmitDelayMs != null) if (receptionDetails?.postTransmitDelayMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.posttxDelay, label: AppLocalizations.of(
context,
)!.posttxDelay,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
@@ -852,7 +857,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.receivedCopies > 1) if (widget.receivedCopies > 1)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.receivedCopies, label: AppLocalizations.of(
context,
)!.receivedCopies,
value: '${widget.receivedCopies}', value: '${widget.receivedCopies}',
), ),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
@@ -883,7 +890,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.retryAttempt > 0) if (widget.message.retryAttempt > 0)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryAttempt, label: AppLocalizations.of(
context,
)!.retryAttempt,
value: '${widget.message.retryAttempt}/4', value: '${widget.message.retryAttempt}/4',
), ),
if (widget.message.lastRetryAt != null) if (widget.message.lastRetryAt != null)
@@ -900,21 +909,27 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.usedFloodFallback) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.floodFallback, label: AppLocalizations.of(
context,
)!.floodFallback,
value: AppLocalizations.of(context)!.yes, value: AppLocalizations.of(context)!.yes,
), ),
if (routeMetadata?.canonicalPath if (routeMetadata?.canonicalPath
case final routePath?) case final routePath?)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.selectedPath, label: AppLocalizations.of(
context,
)!.selectedPath,
value: routePath, value: routePath,
onCopy: () => copyField(routePath), onCopy: () => copyField(routePath),
), ),
if (retryResult != null) if (retryResult != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryResult, label: AppLocalizations.of(
context,
)!.retryResult,
value: retryResult, value: retryResult,
), ),
if (packetPathHex != null) if (packetPathHex != null)
@@ -964,7 +979,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (recipientPrefixHex != null) if (recipientPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.recipientKey, label: AppLocalizations.of(
context,
)!.recipientKey,
value: recipientPrefixHex, value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex), onCopy: () => copyField(recipientPrefixHex),
), ),
@@ -994,7 +1011,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.sessionProgress, label: AppLocalizations.of(
context,
)!.sessionProgress,
value: value:
'${voiceSession.receivedCount}/${voiceSession.total} segments', '${voiceSession.receivedCount}/${voiceSession.total} segments',
), ),
@@ -1009,14 +1028,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.transfers, label: AppLocalizations.of(
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.downloadedBy, label: AppLocalizations.of(
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1024,7 +1047,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.estimatedTx, label: AppLocalizations.of(
context,
)!.estimatedTx,
value: voiceTxEstimate.inSeconds < 60 value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s' ? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
@@ -1075,14 +1100,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.transfers, label: AppLocalizations.of(
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.downloadedBy, label: AppLocalizations.of(
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1090,7 +1119,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.estimatedTx, label: AppLocalizations.of(
context,
)!.estimatedTx,
value: imageTxEstimate.inSeconds < 60 value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s' ? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
@@ -1438,7 +1469,10 @@ class _MessageBubbleState extends State<MessageBubble> {
// Share the location // Share the location
SharePlus.instance.share( 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<MessageBubble> {
if (!widget.isCompact && if (!widget.isCompact &&
!isSarMarker && !isSarMarker &&
!message.isDrawing && !message.isDrawing &&
!message.isSentMessage && !message.isSentMessage) ...[
_showReceivedStats) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
buildReceivedSignalStatus( buildReceivedSignalStatus(
context, context,
@@ -2673,7 +2706,6 @@ class _MessageBubbleState extends State<MessageBubble> {
message.isContactMessage && message.isContactMessage &&
message.deliveryStatus == message.deliveryStatus ==
MessageDeliveryStatus.delivered && MessageDeliveryStatus.delivered &&
_showReceivedStats &&
message.roundTripTimeMs != null; message.roundTripTimeMs != null;
return Column( return Column(
@@ -2779,10 +2811,7 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
}, },
), ),
if (shouldShowSentChannelStats( if (shouldShowSentChannelStats(message)) ...[
message,
showReceivedStats: _showReceivedStats,
)) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
buildChannelEchoStatus(context, message), buildChannelEchoStatus(context, message),
], ],

View File

@@ -92,10 +92,7 @@ Widget buildChannelEchoStatus(BuildContext context, Message message) {
); );
} }
bool shouldShowSentChannelStats( bool shouldShowSentChannelStats(Message message) {
Message message, {
required bool showReceivedStats,
}) {
if (!message.isSentMessage || !message.isChannelMessage) { if (!message.isSentMessage || !message.isChannelMessage) {
return false; return false;
} }
@@ -105,7 +102,7 @@ bool shouldShowSentChannelStats(
message.lastEchoRssiDbm != null || message.lastEchoRssiDbm != null ||
message.lastEchoSnrRaw != null || message.lastEchoSnrRaw != null ||
message.expectedAckTag != null; message.expectedAckTag != null;
return showReceivedStats && hasSignalData; return hasSignalData;
} }
Widget buildReceivedSignalStatus( Widget buildReceivedSignalStatus(

View File

@@ -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<int>.generate(6, (index) => seed + index));
Uint8List _key(int seed) =>
Uint8List.fromList(List<int>.generate(32, (index) => seed + index));
Future<void> _disposeHarness(WidgetTester tester, _TestHarness harness) async {
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
await tester.pump();
}