mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add transmission timing details
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_avif/flutter_avif.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/image_provider.dart' as ip;
|
||||
@@ -254,11 +255,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final conn = context.read<ConnectionProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
imageProvider.resumeIncomingSession(envelope.sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
final appProvider = context.read<AppProvider>();
|
||||
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: conn.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
@@ -271,6 +278,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
@@ -278,6 +286,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
@@ -285,14 +294,76 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unreachable) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route did not respond to a path check. Sync contacts/path and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = resolution.target!;
|
||||
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
await conn.getContacts();
|
||||
if (!mounted) return;
|
||||
resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: conn.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
sender = resolution.target!;
|
||||
routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route did not respond on the raw transport path.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final sender = resolution.target!;
|
||||
if (sender.outPathLen >= 2) {
|
||||
_showToast(
|
||||
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
||||
@@ -302,6 +373,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
setState(() => _errorText = null);
|
||||
final deviceKey = conn.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Device key is unavailable.',
|
||||
@@ -332,11 +404,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final payload = request.encodeBinary();
|
||||
try {
|
||||
await conn.sendRawVoicePacket(
|
||||
@@ -405,6 +472,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRequestState() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showBlockingAlert(String title, String message) async {
|
||||
if (!mounted) return;
|
||||
_showToast('$title: $message');
|
||||
|
||||
@@ -23,15 +23,16 @@ import '../../utils/key_comparison.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
import 'tictactoe_message_bubble.dart';
|
||||
import 'message_trace_sheet.dart';
|
||||
import 'message_bubble_header.dart';
|
||||
import 'message_bubble_signal.dart';
|
||||
import 'system_message_bubble.dart';
|
||||
|
||||
/// Reusable message bubble widget that displays messages with various types:
|
||||
/// - Regular text messages (channel or direct)
|
||||
@@ -65,104 +66,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
bool _isExpanded = false;
|
||||
bool _showReceivedStats = false;
|
||||
|
||||
Widget _buildHeaderAvatar(
|
||||
BuildContext context, {
|
||||
required bool isOwnMessage,
|
||||
required bool isChannelMessage,
|
||||
required dynamic senderContact,
|
||||
required String displayName,
|
||||
}) {
|
||||
if (isOwnMessage) {
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.account_circle,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (senderContact is Contact) {
|
||||
return ContactAvatar(
|
||||
contact: senderContact,
|
||||
radius: 10.5,
|
||||
displayName: displayName,
|
||||
);
|
||||
}
|
||||
|
||||
final background = isChannelMessage
|
||||
? Colors.teal.withValues(alpha: 0.16)
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
final foreground = isChannelMessage
|
||||
? Colors.teal.shade800
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: background,
|
||||
child: Text(
|
||||
AvatarLabelHelper.buildLabel(displayName),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBubbleMetaFooter(
|
||||
BuildContext context, {
|
||||
required Message message,
|
||||
required bool isSarMarker,
|
||||
}) {
|
||||
final metaColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||
|
||||
final items = <Widget>[];
|
||||
|
||||
if (!isSarMarker && message.pathLen < 255) {
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
items.add(
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: items),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MessageBubble oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -508,6 +411,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
final senderLocationSnapshot = messagesProvider.getMessageContactLocation(
|
||||
widget.message.id,
|
||||
);
|
||||
final receptionDetails = messagesProvider.getMessageReceptionDetails(
|
||||
widget.message.id,
|
||||
);
|
||||
|
||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
||||
@@ -572,16 +478,19 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
widget.message,
|
||||
);
|
||||
final packetPathBytes = _extractPathBytesFromLog(matchedRxLog);
|
||||
final packetPathHex = packetPathBytes
|
||||
final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes)
|
||||
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
final snrDb =
|
||||
receptionDetails?.snrDb ??
|
||||
matchedRxLog?.logRxDataInfo?.snrDb ??
|
||||
(widget.message.lastEchoSnrRaw != null
|
||||
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
|
||||
: null);
|
||||
final rssiDbm =
|
||||
matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm;
|
||||
receptionDetails?.rssiDbm ??
|
||||
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
||||
widget.message.lastEchoRssiDbm;
|
||||
final retryCause = _retryCauseLabel(widget.message);
|
||||
final retryResult = _retryResultLabel(widget.message);
|
||||
final retryMode = _retryModeLabel(widget.message);
|
||||
@@ -604,6 +513,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
'Matched RX RSSI: ${rssiDbm ?? '-'}',
|
||||
'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
|
||||
'Matched path bytes: ${packetPathHex ?? '-'}',
|
||||
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
|
||||
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
|
||||
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
|
||||
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
||||
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
||||
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
||||
@@ -767,7 +679,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_techBadge(
|
||||
context,
|
||||
icon: Icons.route,
|
||||
label: _hopDisplayLabel(widget.message),
|
||||
label: hopDisplayLabel(widget.message),
|
||||
),
|
||||
_techBadge(
|
||||
context,
|
||||
@@ -855,6 +767,30 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
value: widget.message.expectedAckTag!
|
||||
.toString(),
|
||||
),
|
||||
if (receptionDetails?.senderToReceiptMs != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Sender to receipt',
|
||||
value: _formatDurationMs(
|
||||
receptionDetails!.senderToReceiptMs!,
|
||||
),
|
||||
),
|
||||
if (receptionDetails?.estimatedTransmitMs != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Estimated tx',
|
||||
value: _formatDurationMs(
|
||||
receptionDetails!.estimatedTransmitMs!,
|
||||
),
|
||||
),
|
||||
if (receptionDetails?.postTransmitDelayMs != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Post-tx delay',
|
||||
value: _formatDurationMs(
|
||||
receptionDetails!.postTransmitDelayMs!,
|
||||
),
|
||||
),
|
||||
if (widget.message.suggestedTimeoutMs != null)
|
||||
_detailRow(
|
||||
context,
|
||||
@@ -1263,6 +1199,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
'${fraction}Z';
|
||||
}
|
||||
|
||||
String _formatDurationMs(int durationMs) {
|
||||
if (durationMs >= 60000) {
|
||||
final minutes = durationMs ~/ 60000;
|
||||
final seconds = (durationMs % 60000) ~/ 1000;
|
||||
return '${minutes}m ${seconds}s';
|
||||
}
|
||||
if (durationMs >= 1000) {
|
||||
return '${(durationMs / 1000).toStringAsFixed(durationMs >= 10000 ? 0 : 1)} s';
|
||||
}
|
||||
return '$durationMs ms';
|
||||
}
|
||||
|
||||
BlePacketLog? _findBestMatchingRxLog(
|
||||
List<BlePacketLog> logs,
|
||||
Message message,
|
||||
@@ -1591,225 +1539,12 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Icons.schedule;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Icons.check;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Icons.done_all;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Icons.error_outline;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Icons.inbox;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getDeliveryStatusColor(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Colors.orange;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Colors.blue;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Colors.green;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Colors.red;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChannelEchoStatus(BuildContext context, Message message) {
|
||||
final hasEcho = message.echoCount > 0;
|
||||
|
||||
if (!hasEcho) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final statusColor = _getDeliveryStatusColor(message.deliveryStatus);
|
||||
final rssi = message.lastEchoRssiDbm;
|
||||
final snr = message.lastEchoSnrRaw != null
|
||||
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
|
||||
: null;
|
||||
final quality = _linkQualityLabel(rssi, snr);
|
||||
final qualityColor = _linkQualityColor(quality);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hub_outlined,
|
||||
label: 'x${message.echoCount}',
|
||||
color: statusColor,
|
||||
),
|
||||
if (message.expectedAckTag != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.tag,
|
||||
label:
|
||||
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.bolt,
|
||||
label: quality,
|
||||
color: qualityColor,
|
||||
),
|
||||
if (message.lastEchoRssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: message.lastEchoRssiDbm!.toString(),
|
||||
filled: _rssiScore(message.lastEchoRssiDbm!),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (message.lastEchoSnrRaw != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(
|
||||
1,
|
||||
),
|
||||
filled: _snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldShowSentChannelStats(Message message) {
|
||||
if (!message.isSentMessage || !message.isChannelMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasSignalData =
|
||||
message.echoCount > 0 ||
|
||||
message.lastEchoRssiDbm != null ||
|
||||
message.lastEchoSnrRaw != null ||
|
||||
message.expectedAckTag != null;
|
||||
return _showReceivedStats && hasSignalData;
|
||||
}
|
||||
|
||||
Widget _buildReceivedSignalStatus(
|
||||
BuildContext context,
|
||||
Message message, {
|
||||
required int? rssiDbm,
|
||||
required double? snrDb,
|
||||
}) {
|
||||
final hopLabel = _hopDisplayLabel(message);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopLabel,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
if (rssiDbm != null || snrDb != null) ...[
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.bolt,
|
||||
label: _linkQualityLabel(rssiDbm, snrDb),
|
||||
color: _linkQualityColor(_linkQualityLabel(rssiDbm, snrDb)),
|
||||
),
|
||||
if (rssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: '$rssiDbm',
|
||||
filled: _rssiScore(rssiDbm),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (snrDb != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: snrDb.toStringAsFixed(1),
|
||||
filled: _snrScore(snrDb),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _hopDisplayLabel(Message message) {
|
||||
if (message.pathLen == 0) return 'Direct';
|
||||
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||
if (message.pathLen >= 255) return 'Unknown';
|
||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
Widget _buildChannelHeaderPill(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
IconData icon = Icons.campaign_outlined,
|
||||
}) {
|
||||
final labelColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectHeaderCounterpart(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
}) {
|
||||
return _buildChannelHeaderPill(
|
||||
context,
|
||||
label: label,
|
||||
icon: Icons.alternate_email,
|
||||
);
|
||||
}
|
||||
|
||||
String _hopDebugLabel(Message message) {
|
||||
if (message.pathLen >= 255 && message.isContactMessage) {
|
||||
return 'Direct (raw: ${message.pathLen})';
|
||||
}
|
||||
if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})';
|
||||
return _hopDisplayLabel(message);
|
||||
return hopDisplayLabel(message);
|
||||
}
|
||||
|
||||
String? _retryCauseLabel(Message message) {
|
||||
@@ -1891,110 +1626,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _techChip(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _signalCapsule(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required int filled,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(5, (i) {
|
||||
final active = i < filled;
|
||||
return Container(
|
||||
width: 3,
|
||||
height: (4 + i).toDouble(),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color : color.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
||||
|
||||
int _snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
|
||||
|
||||
String _linkQualityLabel(int? rssiDbm, double? snrDb) {
|
||||
var score = 0;
|
||||
if (rssiDbm != null) score += _rssiScore(rssiDbm);
|
||||
if (snrDb != null) score += _snrScore(snrDb);
|
||||
if (score >= 8) return 'Excellent';
|
||||
if (score >= 6) return 'Good';
|
||||
if (score >= 4) return 'Fair';
|
||||
return 'Weak';
|
||||
}
|
||||
|
||||
Color _linkQualityColor(String quality) {
|
||||
switch (quality) {
|
||||
case 'Excellent':
|
||||
return Colors.green;
|
||||
case 'Good':
|
||||
return Colors.lightGreen;
|
||||
case 'Fair':
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Display system messages with minimal styling
|
||||
@@ -2015,9 +1646,13 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
|
||||
// Determine if this is own message
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final isOwnMessage =
|
||||
message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||
final receptionDetails = !isOwnMessage
|
||||
? messagesProvider.getMessageReceptionDetails(message.id)
|
||||
: null;
|
||||
final matchedRxLog = !isOwnMessage
|
||||
? _findBestMatchingRxLog(
|
||||
connectionProvider.bleService.packetLogs,
|
||||
@@ -2025,12 +1660,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
)
|
||||
: null;
|
||||
final snrDb =
|
||||
receptionDetails?.snrDb ??
|
||||
matchedRxLog?.logRxDataInfo?.snrDb ??
|
||||
(message.lastEchoSnrRaw != null
|
||||
? (message.lastEchoSnrRaw!.toSigned(8) / 4.0)
|
||||
: null);
|
||||
final rssiDbm =
|
||||
matchedRxLog?.logRxDataInfo?.rssiDbm ?? message.lastEchoRssiDbm;
|
||||
receptionDetails?.rssiDbm ??
|
||||
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
||||
message.lastEchoRssiDbm;
|
||||
|
||||
// Look up contact information for rich display name
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
@@ -2297,7 +1935,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
_buildHeaderAvatar(
|
||||
buildMessageHeaderAvatar(
|
||||
context,
|
||||
isOwnMessage: isOwnMessage,
|
||||
isChannelMessage: message.isChannelMessage,
|
||||
@@ -2330,7 +1968,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
if (message.isChannelMessage)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _buildChannelHeaderPill(
|
||||
child: buildChannelHeaderPill(
|
||||
context,
|
||||
label: isOwnMessage
|
||||
? recipientDisplayName!
|
||||
@@ -2340,7 +1978,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
else
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _buildDirectHeaderCounterpart(
|
||||
child: buildDirectHeaderCounterpart(
|
||||
context,
|
||||
label: directCounterpartLabel!,
|
||||
),
|
||||
@@ -2636,9 +2274,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
!message.isSentMessage &&
|
||||
_showReceivedStats) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildReceivedSignalStatus(
|
||||
buildReceivedSignalStatus(
|
||||
context,
|
||||
message,
|
||||
receptionDetails: receptionDetails,
|
||||
rssiDbm: rssiDbm,
|
||||
snrDb: snrDb,
|
||||
),
|
||||
@@ -2830,9 +2469,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Icon(
|
||||
_getDeliveryStatusIcon(message.deliveryStatus),
|
||||
getDeliveryStatusIcon(message.deliveryStatus),
|
||||
size: 12,
|
||||
color: _getDeliveryStatusColor(message.deliveryStatus),
|
||||
color: getDeliveryStatusColor(message.deliveryStatus),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
@@ -2844,7 +2483,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: _getDeliveryStatusColor(
|
||||
color: getDeliveryStatusColor(
|
||||
message.deliveryStatus,
|
||||
),
|
||||
fontStyle: FontStyle.italic,
|
||||
@@ -2895,9 +2534,12 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_shouldShowSentChannelStats(message)) ...[
|
||||
if (shouldShowSentChannelStats(
|
||||
message,
|
||||
showReceivedStats: _showReceivedStats,
|
||||
)) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildChannelEchoStatus(context, message),
|
||||
buildChannelEchoStatus(context, message),
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -2912,7 +2554,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
bubble,
|
||||
_buildBubbleMetaFooter(
|
||||
buildBubbleMetaFooter(
|
||||
context,
|
||||
message: message,
|
||||
isSarMarker: isSarMarker,
|
||||
@@ -2932,85 +2574,3 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// System message bubble - compact log-style display
|
||||
class SystemMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
const SystemMessageBubble({super.key, required this.message});
|
||||
|
||||
Color _getLevelColor(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Colors.green;
|
||||
case 'warning':
|
||||
return Colors.orange;
|
||||
case 'error':
|
||||
return Colors.red;
|
||||
case 'info':
|
||||
default:
|
||||
return Colors.blue.shade300;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getLevelIcon(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Icons.check_circle_outline;
|
||||
case 'warning':
|
||||
return Icons.warning_amber_outlined;
|
||||
case 'error':
|
||||
return Icons.error_outline;
|
||||
case 'info':
|
||||
default:
|
||||
return Icons.info_outline;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final level = message.senderName ?? 'info';
|
||||
final levelColor = _getLevelColor(level);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode
|
||||
? levelColor.withValues(alpha: 0.1)
|
||||
: levelColor.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_getLevelIcon(level), size: 14, color: levelColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
160
lib/widgets/messages/message_bubble_header.dart
Normal file
160
lib/widgets/messages/message_bubble_header.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
|
||||
Widget buildMessageHeaderAvatar(
|
||||
BuildContext context, {
|
||||
required bool isOwnMessage,
|
||||
required bool isChannelMessage,
|
||||
required dynamic senderContact,
|
||||
required String displayName,
|
||||
}) {
|
||||
if (isOwnMessage) {
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.account_circle,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (senderContact is Contact) {
|
||||
return ContactAvatar(
|
||||
contact: senderContact,
|
||||
radius: 10.5,
|
||||
displayName: displayName,
|
||||
);
|
||||
}
|
||||
|
||||
final background = isChannelMessage
|
||||
? Colors.teal.withValues(alpha: 0.16)
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
final foreground = isChannelMessage
|
||||
? Colors.teal.shade800
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: background,
|
||||
child: Text(
|
||||
AvatarLabelHelper.buildLabel(displayName),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBubbleMetaFooter(
|
||||
BuildContext context, {
|
||||
required Message message,
|
||||
required bool isSarMarker,
|
||||
}) {
|
||||
final metaColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||
|
||||
final items = <Widget>[];
|
||||
|
||||
if (!isSarMarker && message.pathLen < 255) {
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
items.add(
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: items),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChannelHeaderPill(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
IconData icon = Icons.campaign_outlined,
|
||||
}) {
|
||||
final labelColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDirectHeaderCounterpart(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
}) {
|
||||
return buildChannelHeaderPill(
|
||||
context,
|
||||
label: label,
|
||||
icon: Icons.alternate_email,
|
||||
);
|
||||
}
|
||||
303
lib/widgets/messages/message_bubble_signal.dart
Normal file
303
lib/widgets/messages/message_bubble_signal.dart
Normal file
@@ -0,0 +1,303 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_reception_details.dart';
|
||||
|
||||
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Icons.schedule;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Icons.done;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Icons.done_all;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Icons.error_outline;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Icons.inbox;
|
||||
}
|
||||
}
|
||||
|
||||
Color getDeliveryStatusColor(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Colors.orange;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Colors.blue;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Colors.green;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Colors.red;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildChannelEchoStatus(BuildContext context, Message message) {
|
||||
final hasEcho = message.echoCount > 0;
|
||||
|
||||
if (!hasEcho) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final statusColor = getDeliveryStatusColor(message.deliveryStatus);
|
||||
final rssi = message.lastEchoRssiDbm;
|
||||
final snr = message.lastEchoSnrRaw != null
|
||||
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
|
||||
: null;
|
||||
final quality = linkQualityLabel(rssi, snr);
|
||||
final qualityColor = linkQualityColor(quality);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hub_outlined,
|
||||
label: 'x${message.echoCount}',
|
||||
color: statusColor,
|
||||
),
|
||||
if (message.expectedAckTag != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.tag,
|
||||
label:
|
||||
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(context, icon: Icons.bolt, label: quality, color: qualityColor),
|
||||
if (message.lastEchoRssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: message.lastEchoRssiDbm!.toString(),
|
||||
filled: rssiScore(message.lastEchoRssiDbm!),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (message.lastEchoSnrRaw != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1),
|
||||
filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool shouldShowSentChannelStats(
|
||||
Message message, {
|
||||
required bool showReceivedStats,
|
||||
}) {
|
||||
if (!message.isSentMessage || !message.isChannelMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasSignalData =
|
||||
message.echoCount > 0 ||
|
||||
message.lastEchoRssiDbm != null ||
|
||||
message.lastEchoSnrRaw != null ||
|
||||
message.expectedAckTag != null;
|
||||
return showReceivedStats && hasSignalData;
|
||||
}
|
||||
|
||||
Widget buildReceivedSignalStatus(
|
||||
BuildContext context,
|
||||
Message message, {
|
||||
MessageReceptionDetails? receptionDetails,
|
||||
required int? rssiDbm,
|
||||
required double? snrDb,
|
||||
}) {
|
||||
final hopLabel = hopDisplayLabel(message);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopLabel,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
if (receptionDetails?.senderToReceiptMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.schedule,
|
||||
label: _formatMs(receptionDetails!.senderToReceiptMs!),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
if (receptionDetails?.estimatedTransmitMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.timelapse,
|
||||
label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx',
|
||||
color: Colors.blue,
|
||||
),
|
||||
if (receptionDetails?.postTransmitDelayMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hourglass_bottom,
|
||||
label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag',
|
||||
color: Colors.orange,
|
||||
),
|
||||
if (receptionDetails?.pathBytesHex != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.route,
|
||||
label: receptionDetails!.pathBytesHex!,
|
||||
color: Colors.brown,
|
||||
),
|
||||
if (rssiDbm != null || snrDb != null) ...[
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.bolt,
|
||||
label: linkQualityLabel(rssiDbm, snrDb),
|
||||
color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)),
|
||||
),
|
||||
if (rssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: '$rssiDbm',
|
||||
filled: rssiScore(rssiDbm),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (snrDb != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: snrDb.toStringAsFixed(1),
|
||||
filled: snrScore(snrDb),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatMs(int value) {
|
||||
if (value >= 60000) {
|
||||
final minutes = value ~/ 60000;
|
||||
final seconds = (value % 60000) ~/ 1000;
|
||||
return '${minutes}m ${seconds}s';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s';
|
||||
}
|
||||
return '${value}ms';
|
||||
}
|
||||
|
||||
String hopDisplayLabel(Message message) {
|
||||
if (message.pathLen == 0) return 'Direct';
|
||||
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||
if (message.pathLen >= 255) return 'Unknown';
|
||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
Widget _techChip(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _signalCapsule(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required int filled,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(5, (i) {
|
||||
final active = i < filled;
|
||||
return Container(
|
||||
width: 3,
|
||||
height: (4 + i).toDouble(),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color : color.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
||||
|
||||
int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
|
||||
|
||||
String linkQualityLabel(int? rssiDbm, double? snrDb) {
|
||||
var score = 0;
|
||||
if (rssiDbm != null) score += rssiScore(rssiDbm);
|
||||
if (snrDb != null) score += snrScore(snrDb);
|
||||
if (score >= 8) return 'Excellent';
|
||||
if (score >= 6) return 'Good';
|
||||
if (score >= 4) return 'Fair';
|
||||
return 'Weak';
|
||||
}
|
||||
|
||||
Color linkQualityColor(String quality) {
|
||||
switch (quality) {
|
||||
case 'Excellent':
|
||||
return Colors.green;
|
||||
case 'Good':
|
||||
return Colors.lightGreen;
|
||||
case 'Fair':
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
107
lib/widgets/messages/system_message_bubble.dart
Normal file
107
lib/widgets/messages/system_message_bubble.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
|
||||
class SystemMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
const SystemMessageBubble({super.key, required this.message});
|
||||
|
||||
Color _getLevelColor(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Colors.green;
|
||||
case 'warning':
|
||||
return Colors.orange;
|
||||
case 'error':
|
||||
return Colors.red;
|
||||
case 'info':
|
||||
default:
|
||||
return Colors.blue.shade300;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getLevelIcon(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Icons.check_circle_outline;
|
||||
case 'warning':
|
||||
return Icons.warning_amber_outlined;
|
||||
case 'error':
|
||||
return Icons.error_outline;
|
||||
case 'info':
|
||||
default:
|
||||
return Icons.info_outline;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final level = message.senderName ?? 'info';
|
||||
final levelColor = _getLevelColor(level);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(_getLevelIcon(level), size: 16, color: levelColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
level.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
height: 1.3,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
@@ -218,11 +219,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_autoPlayWhenReady = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final voiceProvider = context.read<VoiceProvider>();
|
||||
voiceProvider.resumeIncomingSession(sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
final appProvider = context.read<AppProvider>();
|
||||
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: connectionProvider.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
@@ -235,6 +243,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
@@ -242,6 +251,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
@@ -249,27 +259,85 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unreachable) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route did not respond to a path check. Sync contacts/path and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = resolution.target!;
|
||||
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
await connectionProvider.getContacts();
|
||||
if (!mounted) return;
|
||||
resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: connectionProvider.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
sender = resolution.target!;
|
||||
routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route did not respond on the raw transport path.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final sender = resolution.target!;
|
||||
if (sender.outPathLen >= 2) {
|
||||
_showToast(
|
||||
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Device key is unavailable.',
|
||||
@@ -305,12 +373,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
version: 2,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_autoPlayWhenReady = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
@@ -373,6 +435,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRequestState() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_autoPlayWhenReady = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReceive(String sessionId) {
|
||||
if (!mounted) return;
|
||||
_requestTimeoutTimer?.cancel();
|
||||
|
||||
Reference in New Issue
Block a user