Push meshcore_client and refresh pub

This commit is contained in:
Janez T
2026-03-05 20:58:29 +01:00
parent 2b34bc4162
commit 2482f94cd0
15 changed files with 885 additions and 291 deletions

View File

@@ -68,8 +68,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen =
sender != null && sender.outPathLen >= 0
final effectivePathLen = sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId);
@@ -79,7 +78,12 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isRequesting = false);
if (mounted) {
setState(() {
_isRequesting = false;
_errorText = null;
});
}
});
}
@@ -189,8 +193,38 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
'$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
Positioned(
top: 8,
right: 8,
child: IconButton(
onPressed: () => _cancelReceive(envelope.sessionId),
icon: const Icon(Icons.close, size: 20),
color: Colors.white70,
tooltip: 'Cancel image receive',
),
),
] else if (_errorText != null) ...[
const Icon(Icons.broken_image, color: Colors.red, size: 36),
Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image, color: Colors.red, size: 36),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Retry'),
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
),
),
],
),
] else ...[
// Tap-to-load icon.
IconButton(
@@ -221,6 +255,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}) async {
if (_isRequesting) return;
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(
contactsProvider: contactsProvider,
@@ -264,7 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}
setState(() => _errorText = null);
final imageProvider = context.read<ip.ImageProvider>();
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
await _showBlockingAlert(
@@ -322,7 +357,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen
: pathLen;
final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty
@@ -357,6 +394,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
);
}
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
_showToast('Image receive canceled');
setState(() {
_isRequesting = false;
_errorText = 'Image receive canceled';
});
}
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
_showToast('$title: $message');

View File

@@ -468,7 +468,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sent message: ${widget.message.isSentMessage}',
'Read: ${widget.message.isRead}',
'Status: ${widget.message.deliveryStatus.name}',
'Path length (nodes/hops): ${widget.message.pathLen}',
'Path length (nodes/hops): ${_hopDebugLabel(widget.message)}',
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
'Channel index: ${widget.message.channelIdx ?? '-'}',
@@ -635,8 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_techBadge(
context,
icon: Icons.route,
label:
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
label: _hopDisplayLabel(widget.message),
),
_techBadge(
context,
@@ -1527,9 +1526,7 @@ class _MessageBubbleState extends State<MessageBubble> {
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = message.pathLen == 0
? 'Direct'
: '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
final hopLabel = _hopDisplayLabel(message);
return Wrap(
spacing: 4,
@@ -1570,6 +1567,21 @@ class _MessageBubbleState extends State<MessageBubble> {
);
}
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'}';
}
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);
}
Widget _techChip(
BuildContext context, {
required IconData icon,
@@ -1736,9 +1748,10 @@ class _MessageBubbleState extends State<MessageBubble> {
: message.getRichDisplayName(senderContact);
final l10n = AppLocalizations.of(context)!;
// For sent direct/channel messages, look up destination display label
// Look up destination/source display labels for direct/channel messages
dynamic recipientContact;
String? recipientDisplayName;
String? channelDisplayName;
if (isOwnMessage &&
message.isContactMessage &&
message.recipientPublicKey != null) {
@@ -1766,103 +1779,123 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientContact.displayName ?? recipientContact.advName;
}
}
} else if (isOwnMessage && message.isChannelMessage) {
} else if (message.isChannelMessage) {
if (message.channelIdx == 0) {
recipientDisplayName = l10n.publicChannel;
channelDisplayName = l10n.publicChannel;
} else {
final channelContact = contactsProvider.channels.where((c) {
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
}).firstOrNull;
recipientDisplayName =
channelDisplayName =
channelContact?.getLocalizedDisplayName(context) ??
'${l10n.channel} ${message.channelIdx}';
}
if (isOwnMessage) {
recipientDisplayName = channelDisplayName;
}
}
final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName;
final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${l10n.channel}: $channelDisplayName'
: null;
return GestureDetector(
onTap: () => _handleBubbleTap(
isSarMarker: isSarMarker,
isDrawing: message.isDrawing,
final shouldFloatBubble = message.isChannelMessage || widget.isCompact;
final bubble = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: shouldFloatBubble
? MediaQuery.of(context).size.width * 0.78
: double.infinity,
),
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: widget.isHighlighted
? Theme.of(context).colorScheme.primaryContainer
: isSarMarker
? _getSarMarkerColor(context, isDarkMode)
: message.isDrawing
? (isDarkMode
? Theme.of(
child: GestureDetector(
onTap: () => _handleBubbleTap(
isSarMarker: isSarMarker,
isDrawing: message.isDrawing,
),
onLongPress: widget.isCompact
? null
: () => _showMessageOptions(context),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: widget.isHighlighted
? Theme.of(context).colorScheme.primaryContainer
: isSarMarker
? _getSarMarkerColor(context, isDarkMode)
: message.isDrawing
? (isDarkMode
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.15)
: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08))
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
borderRadius: BorderRadius.circular(12),
border: widget.isHighlighted
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
)
: isSarMarker
? Border.all(
color: _getSarMarkerBorderColor(context, isDarkMode),
width: 2,
)
: message.isDrawing
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.15)
: Theme.of(
).colorScheme.primary.withValues(alpha: 0.4),
width: 2,
)
: isOwnMessage
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08))
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
borderRadius: BorderRadius.circular(12),
border: widget.isHighlighted
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
)
: isSarMarker
? Border.all(
color: _getSarMarkerBorderColor(context, isDarkMode),
width: 2,
)
: message.isDrawing
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.4),
width: 2,
)
: isOwnMessage
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
width: 1.5,
)
: !message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage
? Border.all(color: Colors.blue, width: 1.5)
: null,
boxShadow: widget.isHighlighted
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.5),
blurRadius: 12,
spreadRadius: 2,
offset: const Offset(0, 2),
),
]
: isSarMarker || message.isDrawing
? [
BoxShadow(
color:
(isSarMarker
? _getSarMarkerBorderColor(context, isDarkMode)
: Theme.of(context).colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Column(
).colorScheme.primary.withValues(alpha: 0.3),
width: 1.5,
)
: !message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage
? Border.all(color: Colors.blue, width: 1.5)
: null,
boxShadow: widget.isHighlighted
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.5),
blurRadius: 12,
spreadRadius: 2,
offset: const Offset(0, 2),
),
]
: isSarMarker || message.isDrawing
? [
BoxShadow(
color:
(isSarMarker
? _getSarMarkerBorderColor(
context,
isDarkMode,
)
: Theme.of(context).colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Badge (if SAR or drawing) and time
@@ -1984,15 +2017,17 @@ class _MessageBubbleState extends State<MessageBubble> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Show destination for sent direct/channel messages on a separate line
if (isOwnMessage &&
recipientSubtitle != null &&
!widget.isCompact) ...[
// Show destination/source context on a separate line.
if (!widget.isCompact &&
(recipientSubtitle != null ||
receivedChannelSubtitle != null)) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.arrow_forward,
isOwnMessage
? Icons.arrow_forward
: Icons.arrow_back,
size: 12,
color: Theme.of(context)
.textTheme
@@ -2003,7 +2038,9 @@ class _MessageBubbleState extends State<MessageBubble> {
const SizedBox(width: 4),
Expanded(
child: Text(
recipientSubtitle,
isOwnMessage
? recipientSubtitle!
: receivedChannelSubtitle!,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Theme.of(context)
@@ -2460,9 +2497,21 @@ class _MessageBubbleState extends State<MessageBubble> {
),
],
],
),
),
),
),
);
if (!shouldFloatBubble) {
return bubble;
}
return Row(
mainAxisAlignment: isOwnMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [Flexible(child: bubble)],
);
}
}

View File

@@ -65,8 +65,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen =
sender != null && sender.outPathLen >= 0
final effectivePathLen = sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId);
@@ -77,6 +76,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return;
setState(() {
_isRequesting = false;
_errorText = null;
});
});
}
@@ -125,6 +125,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
await voiceProvider.stop();
return;
}
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
@@ -151,7 +155,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
child: Icon(
isPlaying
? Icons.stop
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
: (_isRequesting ? Icons.close : Icons.play_arrow),
size: 28,
color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer
@@ -215,6 +219,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}) async {
if (_isRequesting) return;
final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
@@ -300,7 +306,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen
: pathLen;
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: envelope.total,
@@ -333,6 +341,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
});
}
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
_showToast('Voice receive canceled');
setState(() {
_isRequesting = false;
_autoPlayWhenReady = false;
_errorText = 'Voice receive canceled';
});
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(