fix: retain voice codec settings now

ref:
This commit is contained in:
Janez T
2026-03-01 11:00:30 +01:00
parent daeb8aeb9c
commit 474fd02635
34 changed files with 3796 additions and 639 deletions

View File

@@ -11,12 +11,14 @@ import '../../providers/messages_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
import '../../providers/voice_provider.dart';
import '../contacts/direct_message_sheet.dart';
import '../drawing_minimap_preview.dart';
import '../../services/sar_template_service.dart';
import '../../utils/toast_logger.dart';
import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import 'voice_message_bubble.dart';
@@ -264,6 +266,15 @@ class _MessageBubbleState extends State<MessageBubble> {
_hideDrawingFromMap(context);
},
),
// Technical details option
ListTile(
leading: const Icon(Icons.data_object),
title: const Text('Technical details'),
onTap: () {
Navigator.pop(context);
_showTechnicalDetails(context);
},
),
// Delete message option
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
@@ -282,6 +293,534 @@ class _MessageBubbleState extends State<MessageBubble> {
);
}
void _showTechnicalDetails(BuildContext context) {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final voiceProvider = context.read<VoiceProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
widget.message.isSentMessage ||
widget.message.isFromSelf(selfPublicKey);
String? senderName;
if (widget.message.senderPublicKeyPrefix != null) {
final senderKeyHex = widget.message.senderPublicKeyPrefix!
.sublist(
0,
widget.message.senderPublicKeyPrefix!.length < 6
? widget.message.senderPublicKeyPrefix!.length
: 6,
)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final senderContact = contactsProvider.contacts
.where((c) => c.publicKeyHex.startsWith(senderKeyHex))
.firstOrNull;
senderName = senderContact?.advName;
}
String? recipientName;
if (widget.message.recipientPublicKey != null) {
final recipientKeyHex = widget.message.recipientPublicKey!
.sublist(
0,
widget.message.recipientPublicKey!.length < 6
? widget.message.recipientPublicKey!.length
: 6,
)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final recipientContact = contactsProvider.contacts
.where((c) => c.publicKeyHex.startsWith(recipientKeyHex))
.firstOrNull;
recipientName = recipientContact?.advName;
}
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
final voiceSession = widget.message.voiceId != null
? voiceProvider.session(widget.message.voiceId!)
: null;
final senderPrefixHex = widget.message.senderPublicKeyPrefix
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final recipientKey = widget.message.recipientPublicKey;
final recipientPrefixHex = recipientKey
?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final snrDb = widget.message.lastEchoSnrRaw != null
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
: null;
final rawLines = <String>[
'Message ID: ${widget.message.id}',
'Type: ${widget.message.messageType.name}',
'Text type: ${widget.message.textType.name}',
'Own message: $isOwnMessage',
'Sent message: ${widget.message.isSentMessage}',
'Read: ${widget.message.isRead}',
'Status: ${widget.message.deliveryStatus.name}',
'Path length (nodes/hops): ${widget.message.pathLen}',
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
'Received at: ${widget.message.receivedAt.toIso8601String()}',
'Channel index: ${widget.message.channelIdx ?? '-'}',
'Echo count: ${widget.message.echoCount}',
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
'Retry attempt: ${widget.message.retryAttempt}',
'Used flood fallback: ${widget.message.usedFloodFallback}',
'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
'Recipient name: ${recipientName ?? '-'}',
'Drawing flag: ${widget.message.isDrawing}',
'Drawing ID: ${widget.message.drawingId ?? '-'}',
'SAR flag: ${widget.message.isSarMarker}',
'Voice flag: ${widget.message.isVoice}',
'Voice ID: ${widget.message.voiceId ?? '-'}',
'Text length: ${widget.message.text.length}',
];
if (widget.message.isVoice) {
rawLines.add('--- Voice Technical ---');
if (envelope != null) {
rawLines.add('Envelope format: VE1 compact');
rawLines.add(
'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})',
);
rawLines.add('Segments total (envelope): ${envelope.total}');
rawLines.add(
'Estimated duration ms (envelope): ${envelope.durationMs}',
);
rawLines.add('Envelope senderKey6: ${envelope.senderKey6}');
rawLines.add('Envelope ts: ${envelope.timestampSec}');
rawLines.add('Envelope ver: ${envelope.version}');
} else if (legacyVoicePacket != null) {
rawLines.add('Envelope format: legacy V packet');
rawLines.add(
'Legacy segment index/total: ${legacyVoicePacket.index + 1}/${legacyVoicePacket.total}',
);
rawLines.add(
'Legacy codec mode: ${legacyVoicePacket.mode.label} (id=${legacyVoicePacket.mode.id})',
);
} else {
rawLines.add('Envelope format: unknown');
}
if (voiceSession != null) {
rawLines.add('Session present locally: yes');
rawLines.add('Session mode: ${voiceSession.mode.label}');
rawLines.add(
'Session segments received/total: ${voiceSession.receivedCount}/${voiceSession.total}',
);
rawLines.add('Session complete: ${voiceSession.isComplete}');
rawLines.add(
'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}',
);
} else {
rawLines.add('Session present locally: no');
}
}
void copyField(String label, String value) {
Clipboard.setData(ClipboardData(text: value));
ToastLogger.success(context, '$label copied');
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Message technical details'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_techBadge(
context,
icon: Icons.message,
label: widget.message.messageType.name.toUpperCase(),
),
_techBadge(
context,
icon: Icons.route,
label:
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
),
_techBadge(
context,
icon: Icons.account_tree_outlined,
label:
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
),
if (widget.message.channelIdx != null)
_techBadge(
context,
icon: Icons.group_work,
label: 'CH ${widget.message.channelIdx}',
),
],
),
if (widget.message.lastEchoRssiDbm != null ||
snrDb != null) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.network_check,
title: 'Link quality',
child: Column(
children: [
if (widget.message.lastEchoRssiDbm != null)
_signalRow(
context,
label: 'RSSI',
valueLabel: '${widget.message.lastEchoRssiDbm} dBm',
normalized:
((widget.message.lastEchoRssiDbm!.toDouble() +
120.0) /
70.0)
.clamp(0.0, 1.0),
color: widget.message.lastEchoRssiDbm! >= -80
? Colors.green
: widget.message.lastEchoRssiDbm! >= -95
? Colors.amber
: Colors.redAccent,
),
if (snrDb != null) ...[
const SizedBox(height: 8),
_signalRow(
context,
label: 'SNR',
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp(0.0, 1.0),
color: snrDb >= 10
? Colors.green
: snrDb >= 0
? Colors.amber
: Colors.redAccent,
),
],
],
),
),
],
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.tune,
title: 'Delivery',
child: Column(
children: [
_detailRow(
context,
label: 'Status',
value: widget.message.deliveryStatus.name,
),
_detailRow(
context,
label: 'Expected ACK tag',
value: widget.message.expectedAckTag?.toString() ?? '-',
),
_detailRow(
context,
label: 'Round-trip',
value: widget.message.roundTripTimeMs != null
? '${widget.message.roundTripTimeMs} ms'
: '-',
),
_detailRow(
context,
label: 'Retry attempt',
value: widget.message.retryAttempt.toString(),
),
_detailRow(
context,
label: 'Flood fallback',
value: widget.message.usedFloodFallback ? 'Yes' : 'No',
),
],
),
),
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.badge,
title: 'Identity',
child: Column(
children: [
_detailRow(
context,
label: 'Message ID',
value: widget.message.id,
onCopy: () =>
copyField('Message ID', widget.message.id),
),
_detailRow(
context,
label: 'Sender',
value: senderName ?? widget.message.senderName ?? '-',
),
_detailRow(
context,
label: 'Sender key',
value: senderPrefixHex ?? '-',
onCopy: senderPrefixHex != null
? () => copyField('Sender key', senderPrefixHex)
: null,
),
_detailRow(
context,
label: 'Recipient',
value: recipientName ?? '-',
),
_detailRow(
context,
label: 'Recipient key',
value: recipientPrefixHex ?? '-',
onCopy: recipientPrefixHex != null
? () =>
copyField('Recipient key', recipientPrefixHex)
: null,
),
],
),
),
if (widget.message.isVoice) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.graphic_eq,
title: 'Voice',
child: Column(
children: [
_detailRow(
context,
label: 'Voice ID',
value: widget.message.voiceId ?? '-',
),
_detailRow(
context,
label: 'Envelope',
value: envelope != null
? 'VE1 compact'
: legacyVoicePacket != null
? 'Legacy V packet'
: 'Unknown',
),
if (voiceSession != null)
_detailRow(
context,
label: 'Session progress',
value:
'${voiceSession.receivedCount}/${voiceSession.total} segments',
),
if (voiceSession != null)
_detailRow(
context,
label: 'Complete',
value: voiceSession.isComplete ? 'Yes' : 'No',
),
],
),
),
],
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: const Text(
'Raw dump',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
rawLines.join('\n'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
Widget _techSection(
BuildContext context, {
required IconData icon,
required String title,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 14),
const SizedBox(width: 6),
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _techBadge(
BuildContext context, {
required IconData icon,
required String label,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _detailRow(
BuildContext context, {
required String label,
required String value,
VoidCallback? onCopy,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.75),
),
),
),
Expanded(
child: Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
),
),
if (onCopy != null)
IconButton(
onPressed: onCopy,
icon: const Icon(Icons.copy, size: 14),
visualDensity: VisualDensity.compact,
tooltip: 'Copy $label',
),
],
),
);
}
Widget _signalRow(
BuildContext context, {
required String label,
required String valueLabel,
required double normalized,
required Color color,
}) {
return Row(
children: [
SizedBox(
width: 42,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
minHeight: 8,
value: normalized,
backgroundColor: color.withValues(alpha: 0.15),
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 74,
child: Text(
valueLabel,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
);
}
void _showReplySheet(BuildContext context) {
// Find the sender contact by public key prefix
final contactsProvider = context.read<ContactsProvider>();
@@ -595,6 +1134,177 @@ class _MessageBubbleState extends State<MessageBubble> {
}
}
Widget _buildChannelEchoStatus(BuildContext context, Message message) {
final statusColor = _getDeliveryStatusColor(message.deliveryStatus);
final hasEcho = message.echoCount > 0;
if (!hasEcho) {
return Text(
message.getLocalizedDeliveryStatus(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: statusColor,
fontStyle: FontStyle.italic,
),
);
}
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,
),
],
);
}
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
@@ -1265,7 +1975,7 @@ class _MessageBubbleState extends State<MessageBubble> {
// Show single message delivery status
else
Row(
mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.max,
children: [
Icon(
_getDeliveryStatusIcon(message.deliveryStatus),
@@ -1273,11 +1983,26 @@ class _MessageBubbleState extends State<MessageBubble> {
color: _getDeliveryStatusColor(message.deliveryStatus),
),
const SizedBox(width: 3),
Text(
message.getLocalizedDeliveryStatus(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: _getDeliveryStatusColor(message.deliveryStatus),
fontStyle: FontStyle.italic,
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child:
message.isChannelMessage &&
message.deliveryStatus ==
MessageDeliveryStatus.sent
? _buildChannelEchoStatus(context, message)
: Text(
message.getLocalizedDeliveryStatus(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: _getDeliveryStatusColor(
message.deliveryStatus,
),
fontStyle: FontStyle.italic,
),
),
),
),
// Show retry button for failed messages

View File

@@ -1,10 +1,15 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/voice_provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart';
import '../../utils/voice_message_parser.dart';
/// A message bubble that shows a voice recording with play/stop controls.
class VoiceMessageBubble extends StatelessWidget {
class VoiceMessageBubble extends StatefulWidget {
final Message message;
final bool isSentByMe;
@@ -14,49 +19,91 @@ class VoiceMessageBubble extends StatelessWidget {
required this.isSentByMe,
});
@override
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
}
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
bool _isRequesting = false;
bool _autoPlayWhenReady = false;
String? _errorText;
@override
Widget build(BuildContext context) {
final voiceId = message.voiceId;
final voiceId = widget.message.voiceId;
if (voiceId == null) return const SizedBox.shrink();
return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) {
final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_isRequesting = false;
});
});
}
if (_autoPlayWhenReady && isComplete && !isPlaying) {
_autoPlayWhenReady = false;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
await voiceProvider.play(voiceId);
});
}
final received = session?.receivedCount ?? 0;
final total = session?.total ?? 0;
final durationSec = session?.estimatedDurationSeconds ?? 0.0;
final total = session?.total ?? envelope?.total ?? 0;
final playbackProgress = voiceProvider.playbackProgress(voiceId);
final requestProgress = total > 0
? (received / total).clamp(0.0, 1.0)
: null;
final durationSec =
session?.estimatedDurationSeconds ??
((envelope?.durationMs ?? 0) / 1000.0);
final durationLabel = _formatDuration(durationSec);
final modeLabel = session?.mode.label ?? '?';
final modeLabel = session?.mode.label ?? envelope?.mode.label ?? '?';
final waveformBars = _resolveWaveformBars(
session: session,
messageText: widget.message.text,
);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Play / Stop button
InkWell(
onTap: () async {
if (isPlaying) {
await voiceProvider.stop();
} else {
await voiceProvider.play(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
}
await _requestAndPlayVoice(voiceId);
},
borderRadius: BorderRadius.circular(24),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isSentByMe
color: widget.isSentByMe
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
isPlaying ? Icons.stop : Icons.play_arrow,
isPlaying
? Icons.stop
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
size: 28,
color: isSentByMe
color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
),
@@ -67,18 +114,20 @@ class VoiceMessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Waveform placeholder / progress indicator
if (isPlaying)
if (isPlaying || _isRequesting)
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: isPlaying ? playbackProgress : requestProgress,
backgroundColor: Colors.grey.withValues(alpha: 0.3),
),
)
else
_WaveformBar(isComplete: isComplete),
_WaveformBar(
isComplete: isComplete,
bars: waveformBars,
),
const SizedBox(height: 4),
// Duration + mode + packet progress
Text(
_buildStatusText(
durationLabel: durationLabel,
@@ -86,10 +135,14 @@ class VoiceMessageBubble extends StatelessWidget {
received: received,
total: total,
isComplete: isComplete,
isRequesting: _isRequesting,
errorText: _errorText,
),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
@@ -100,6 +153,78 @@ class VoiceMessageBubble extends StatelessWidget {
);
}
Future<void> _requestAndPlayVoice(String sessionId) async {
if (_isRequesting) return;
final sender = _resolveSenderContact();
if (sender == null) {
_setUnavailable();
return;
}
final connectionProvider = context.read<ConnectionProvider>();
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_setUnavailable();
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final request = VoiceFetchRequest(
sessionId: sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
);
setState(() {
_isRequesting = true;
_autoPlayWhenReady = true;
_errorText = null;
});
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encodeText(),
contact: sender,
);
if (!sent) {
_setUnavailable();
}
}
void _setUnavailable() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_autoPlayWhenReady = false;
_errorText = 'Voice unavailable right now';
});
}
Contact? _resolveSenderContact() {
final contactsProvider = context.read<ContactsProvider>();
final senderPrefix = widget.message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
final contact = contactsProvider.findContactByPrefix(
Uint8List.fromList(senderPrefix.sublist(0, 6)),
);
if (contact != null) return contact;
}
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
if (envelope != null) {
final contact = contactsProvider.findContactByPrefixHex(
envelope.senderKey6,
);
if (contact != null) return contact;
}
return null;
}
static String _formatDuration(double seconds) {
final s = seconds.round();
if (s < 60) return '${s}s';
@@ -112,37 +237,69 @@ class VoiceMessageBubble extends StatelessWidget {
required int received,
required int total,
required bool isComplete,
required bool isRequesting,
required String? errorText,
}) {
if (errorText != null) return errorText;
final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) {
return 'Requesting voice$progress';
}
if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress';
}
return '🎙️ $durationLabel · $modeLabel';
}
List<double> _resolveWaveformBars({
required VoiceSession? session,
required String messageText,
}) {
if (session != null) {
final fromSession = VoiceWaveform.buildBarsFromPackets(session.packets);
if (fromSession.any((v) => v > 0.0)) return fromSession;
}
final legacyPacket = VoicePacket.tryParseText(messageText);
if (legacyPacket != null) {
final fromLegacy = VoiceWaveform.buildBarsFromPackets([legacyPacket]);
if (fromLegacy.any((v) => v > 0.0)) return fromLegacy;
}
return const [];
}
}
/// Simple static waveform bar using a row of rectangles.
/// Voice waveform rendered as a row of bars.
class _WaveformBar extends StatelessWidget {
final bool isComplete;
const _WaveformBar({required this.isComplete});
final List<double> bars;
const _WaveformBar({
required this.isComplete,
required this.bars,
});
@override
Widget build(BuildContext context) {
const heights = [8.0, 14.0, 10.0, 18.0, 12.0, 16.0, 10.0, 14.0, 8.0, 12.0, 16.0, 10.0];
final heights = bars.isEmpty
? const [8.0, 12.0, 10.0, 14.0, 9.0, 12.0, 8.0, 11.0, 10.0, 13.0]
: bars.map((v) => 6.0 + (v.clamp(0.0, 1.0) * 14.0)).toList();
final color = isComplete
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
: Colors.grey.withValues(alpha: 0.5);
return Row(
children: heights
.map((h) => Container(
width: 3,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
))
.map(
(h) => Container(
width: 3,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
)
.toList(),
);
}