Add transmission timing details

This commit is contained in:
Janez T
2026-03-07 09:05:55 +01:00
parent 810897d348
commit f6c5a3a4ca
21 changed files with 2010 additions and 602 deletions

View File

@@ -21,10 +21,19 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0;
int _totalToScan = 0;
String? _connectingToServerKey; // Track which server is being connected to (ip:port)
int _lastTabIndex = 0;
String?
_connectingToServerKey; // Track which server is being connected to (ip:port)
// Named listener method for proper cleanup
void _onTabChanged() {
if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) {
// Switched to network tab
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
@@ -47,13 +56,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
_connectionProvider = Provider.of<ConnectionProvider>(
context,
listen: false,
);
// Defer scan startup until after the first frame so Provider listeners
// are not notified while this dialog is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_connectionProvider.startScan();
_refreshBleDevices();
});
// Set up network scanner callbacks
@@ -101,6 +113,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_networkScanner.scan();
}
Future<void> _refreshBleDevices() async {
await _connectionProvider.stopScan();
if (!mounted) return;
await _connectionProvider.startScan();
}
Color _getSignalColor(int rssi) {
if (rssi >= -60) return Colors.green;
if (rssi >= -75) return Colors.orange;
@@ -218,10 +236,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
onPressed: _refreshBleDevices,
),
],
),
@@ -255,10 +270,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
onPressed: _refreshBleDevices,
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.scanAgain),
),

View File

@@ -70,6 +70,9 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>();
final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey,
);
final roomLoginState = contact.type == ContactType.room
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
: null;
@@ -188,6 +191,17 @@ class ContactTile extends StatelessWidget {
),
),
],
if (isPingInProgress) ...[
const SizedBox(width: 6),
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
),
],
],
),
subtitle: isSimpleMode
@@ -458,39 +472,43 @@ class ContactTile extends StatelessWidget {
_showContactDetails(context, contact);
}
},
onLongPress: () async {
final connectionProvider = context.read<ConnectionProvider>();
onLongPress: isPingInProgress
? null
: () async {
final connectionProvider = context.read<ConnectionProvider>();
// Determine if we should use flooding (no path) or direct (has path)
final hasPath = contact.hasPath;
// Determine if we should use flooding (no path) or direct (has path)
final hasPath = contact.hasPath;
// Use smart ping with automatic fallback
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
// Called when retrying with flooding after direct timeout
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
// Use smart ping with automatic fallback
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
// Called when retrying with flooding after direct timeout
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
);
}
},
);
}
},
);
// Show final result
if (context.mounted) {
if (!result.success) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.pingFailed(contact.displayName),
);
}
}
},
// Show final result
if (context.mounted) {
if (!result.success) {
ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.pingFailed(contact.displayName),
);
}
}
},
),
);
}
@@ -603,8 +621,12 @@ class ContactTile extends StatelessWidget {
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) => Column(
children: [
builder: (context, scrollController) {
final isPingInProgress = context
.watch<ConnectionProvider>()
.isPingInProgress(contact.publicKey);
return Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
@@ -847,15 +869,25 @@ class ContactTile extends StatelessWidget {
),
),
TextButton.icon(
onPressed: () {
final connectionProvider = context
.read<ConnectionProvider>();
connectionProvider.requestTelemetry(
contact.publicKey,
zeroHop: true,
);
},
icon: const Icon(Icons.refresh, size: 18),
onPressed: isPingInProgress
? null
: () {
final connectionProvider = context
.read<ConnectionProvider>();
connectionProvider.requestTelemetry(
contact.publicKey,
zeroHop: true,
);
},
icon: isPingInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.refresh),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
@@ -991,8 +1023,9 @@ class ContactTile extends StatelessWidget {
],
),
),
],
),
],
);
},
),
);
}

View File

@@ -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');

View File

@@ -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,
),
),
],
),
);
}
}

View 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,
);
}

View 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;
}
}

View 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),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -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();