Files
meshcore-sar_android/lib/widgets/messages/message_bubble.dart
Janez T ba27843166 feat: UX polish, localization, advert relocation, and map filter fixes
- i18n: translate connection screen + chat tab keys into 13 locales;
  add "Send my contact" (advert) strings in all locales
- feat: move self-advert from home header into composer "+" action menu
  (new lib/utils/advert_helper.dart, always shows Flood/Direct sheet)
- fix: hide-repeaters map toggle was bypassed in simple mode
- fix: simple mode map now shows only favourite chat contacts
- fix: wrap ListTiles in Material (contact_tile secondary actions,
  inferred contact group card) to satisfy Flutter ink assertions
- device-authoritative contact/channel sync and UX review fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:01:10 +02:00

3097 lines
120 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../models/sar_marker.dart';
import '../../models/sar_template.dart';
import '../../models/map_drawing.dart';
import '../../models/map_coordinate_space.dart';
import '../../providers/messages_provider.dart';
import '../../providers/channels_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
import '../../providers/voice_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../../providers/app_provider.dart';
import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart';
import '../../models/message_contact_location.dart';
import '../../services/sar_template_service.dart';
import '../../services/location_tracking_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 '../../utils/image_message_parser.dart';
import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart';
import '../../screens/add_contact_screen.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.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)
/// - SAR markers (styled with SAR-specific colors and badges)
/// - Drawing messages (with minimap preview)
/// - System messages (compact log-style display)
/// - Grouped messages (expandable recipient list)
class MessageBubble extends StatefulWidget {
final Message message;
final int receivedCopies;
final VoidCallback? onTap;
final VoidCallback? onReply;
final bool isHighlighted;
final VoidCallback? onNavigateToMap;
/// Compact mode for fullscreen map overlay (simplified styling)
final bool isCompact;
const MessageBubble({
super.key,
required this.message,
this.receivedCopies = 1,
this.onTap,
this.onReply,
this.isHighlighted = false,
this.onNavigateToMap,
this.isCompact = false,
});
@override
State<MessageBubble> createState() => _MessageBubbleState();
}
class _MessageBubbleState extends State<MessageBubble> {
static const int _meshcoreAdvertMinBytes = 98;
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
static final RegExp _linkPattern = RegExp(
r'(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+',
caseSensitive: false,
);
static final RegExp _rawMeshcoreAdvertPattern = RegExp(
r'(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
);
static final RegExp _messageTokenPattern = RegExp(
r'@\[(.+?)\]|(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+|(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
caseSensitive: false,
);
bool _isExpanded = false;
final List<TapGestureRecognizer> _linkRecognizers = [];
@override
void dispose() {
_disposeLinkRecognizers();
super.dispose();
}
@override
void didUpdateWidget(MessageBubble oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message.id != widget.message.id) {
_isExpanded = false;
return;
}
// Force rebuild when message properties change (especially recipient statuses)
if (oldWidget.message.id == widget.message.id) {
// Same message, but properties might have changed
setState(() {
// Trigger rebuild to show updated delivery counts
});
}
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
});
}
void _disposeLinkRecognizers() {
for (final recognizer in _linkRecognizers) {
recognizer.dispose();
}
_linkRecognizers.clear();
}
String _trimTrailingUrlPunctuation(String value) {
var trimmed = value;
while (trimmed.isNotEmpty) {
final lastChar = trimmed[trimmed.length - 1];
final shouldTrim = switch (lastChar) {
'.' || ',' || '!' || '?' || ':' || ';' => true,
')' => '('.allMatches(trimmed).length < ')'.allMatches(trimmed).length,
']' => '['.allMatches(trimmed).length < ']'.allMatches(trimmed).length,
'}' => '{'.allMatches(trimmed).length < '}'.allMatches(trimmed).length,
_ => false,
};
if (!shouldTrim) {
break;
}
trimmed = trimmed.substring(0, trimmed.length - 1);
}
return trimmed;
}
Uri? _parseMessageLink(String value) {
final trimmed = _trimTrailingUrlPunctuation(value);
if (trimmed.isEmpty) {
return null;
}
if (_isRawMeshcoreAdvert(trimmed)) {
return Uri.parse('meshcore://$trimmed');
}
final normalized = trimmed.startsWith('www.')
? 'https://$trimmed'
: trimmed;
final uri = Uri.tryParse(normalized);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
return uri;
}
bool _isRawMeshcoreAdvert(String value) {
if (!_rawMeshcoreAdvertPattern.hasMatch(value)) {
return false;
}
return value.length.isEven && value.length >= _meshcoreAdvertMinBytes * 2;
}
Future<void> _openMessageLink(String rawUrl) async {
final uri = _parseMessageLink(rawUrl);
if (uri == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.invalidLink);
return;
}
if (uri.scheme.toLowerCase() == 'meshcore') {
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => AddContactScreen(initialAdvert: rawUrl),
),
);
return;
}
try {
if (!await canLaunchUrl(uri)) {
if (!mounted) {
return;
}
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotOpenLink,
);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (error) {
if (!mounted) {
return;
}
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToOpenLink,
);
}
}
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
_disposeLinkRecognizers();
final matches = _messageTokenPattern.allMatches(text).toList();
if (matches.isEmpty) {
return Text(text, style: baseBodyStyle);
}
final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final linkColor = Theme.of(context).colorScheme.primary;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12);
final borderColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.25);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in matches) {
if (match.start > cursor) {
spans.add(
TextSpan(
text: text.substring(cursor, match.start),
style: baseBodyStyle,
),
);
}
final rawMatch = match.group(0) ?? '';
final mentionName = match.group(1)?.trim() ?? '';
if (_mentionPattern.hasMatch(rawMatch) && mentionName.isNotEmpty) {
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Text(
'@$mentionName',
style: baseBodyStyle?.copyWith(
color: textColor,
fontSize: mentionFontSize,
fontWeight: FontWeight.w600,
height: 1.0,
),
),
),
),
);
} else if (_linkPattern.hasMatch(rawMatch) ||
_isRawMeshcoreAdvert(rawMatch)) {
final trimmedUrl = _trimTrailingUrlPunctuation(rawMatch);
final trailingText = rawMatch.substring(trimmedUrl.length);
final uri = _parseMessageLink(rawMatch);
if (uri != null) {
final recognizer = TapGestureRecognizer()
..onTap = () => _openMessageLink(rawMatch);
_linkRecognizers.add(recognizer);
spans.add(
TextSpan(
text: trimmedUrl,
style: baseBodyStyle?.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
recognizer: recognizer,
),
);
if (trailingText.isNotEmpty) {
spans.add(TextSpan(text: trailingText, style: baseBodyStyle));
}
} else {
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
}
} else {
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
}
cursor = match.end;
}
if (cursor < text.length) {
spans.add(TextSpan(text: text.substring(cursor), style: baseBodyStyle));
}
return Text.rich(TextSpan(children: spans, style: baseBodyStyle));
}
void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
if (!widget.message.isRead &&
!widget.message.isSentMessage &&
!widget.message.isSystemMessage) {
context.read<MessagesProvider>().markAsRead(widget.message.id);
}
widget.onTap?.call();
}
Future<void> _retryFailedMessage(
BuildContext context,
Message failedMessage,
) async {
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
try {
Contact? roomContact;
if (failedMessage.messageType == MessageType.contact) {
if (failedMessage.recipientPublicKey == null) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
);
return;
}
final contactsProvider = context.read<ContactsProvider>();
roomContact = contactsProvider.contacts.where((c) {
return c.publicKey.length >=
failedMessage.recipientPublicKey!.length &&
c.publicKey.matches(failedMessage.recipientPublicKey!);
}).firstOrNull;
}
// Resend the message
if (failedMessage.messageType == MessageType.contact) {
// Direct message retry (for SAR markers sent to rooms)
if (failedMessage.recipientPublicKey == null) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
);
return;
}
final prepared = messagesProvider.prepareMessageForRetry(
failedMessage.id,
);
if (!prepared) {
return;
}
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: failedMessage.recipientPublicKey!,
text: failedMessage.text,
messageId: failedMessage.id,
contact: roomContact,
);
if (!context.mounted) return;
if (!sentSuccessfully) {
messagesProvider.markMessageFailed(failedMessage.id);
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToResendMessage,
);
}
} else if (failedMessage.messageType == MessageType.channel) {
final prepared = messagesProvider.prepareMessageForRetry(
failedMessage.id,
);
if (!prepared) {
return;
}
// Channel message retry
await connectionProvider.sendChannelMessage(
channelIdx: failedMessage.channelIdx ?? 0,
text: failedMessage.text,
messageId: failedMessage.id,
);
if (!context.mounted) return;
}
} catch (e) {
if (!context.mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.retryFailed('$e'),
);
}
}
void _showMessageOptions(BuildContext context) {
final parentContext = context;
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
widget.message.isSentMessage ||
widget.message.isFromSelf(selfPublicKey);
showModalBottomSheet(
context: context,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isOwnMessage && widget.onReply != null)
ListTile(
leading: Icon(Icons.reply),
title: Text(l10n.reply),
onTap: () {
Navigator.pop(sheetContext);
widget.onReply?.call();
},
),
// Copy text option
ListTile(
leading: Icon(Icons.copy),
title: Text(l10n.copyText),
onTap: () {
Clipboard.setData(ClipboardData(text: widget.message.text));
Navigator.pop(sheetContext);
ToastLogger.success(parentContext, l10n.textCopiedToClipboard);
},
),
// Save as Template option (only for SAR markers without existing template)
if (widget.message.isSarMarker)
Builder(
builder: (_) {
// Extract emoji from SAR message
final sarInfo = SarMessageParser.parse(widget.message.text);
if (sarInfo == null || sarInfo.emoji.isEmpty) {
return const SizedBox.shrink();
}
// Check if template with this emoji already exists
final sarTemplateService = SarTemplateService();
final templateExists = sarTemplateService.templates.any(
(t) => t.emoji == sarInfo.emoji,
);
if (templateExists) {
return const SizedBox.shrink();
}
return ListTile(
leading: Icon(Icons.bookmark_add),
title: Text(l10n.saveAsTemplate),
onTap: () {
Navigator.pop(sheetContext);
_saveAsTemplate(parentContext);
},
);
},
),
// Share location option (only for SAR markers with GPS coordinates)
if (widget.message.isSarMarker &&
widget.message.sarGpsCoordinates != null)
ListTile(
leading: Icon(Icons.share_location),
title: Text(l10n.shareLocation),
onTap: () {
Navigator.pop(sheetContext);
_shareLocation(parentContext);
},
),
// Navigate to drawing option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile(
leading: Icon(Icons.map),
title: Text(l10n.navigateToDrawing),
onTap: () {
Navigator.pop(sheetContext);
_navigateToDrawing(parentContext);
},
),
// Copy coordinates option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile(
leading: Icon(Icons.copy),
title: Text(l10n.copyCoordinates),
onTap: () {
Navigator.pop(sheetContext);
_copyDrawingCoordinates(parentContext);
},
),
// Delete drawing & message option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile(
leading: Icon(Icons.delete_forever, color: Colors.red),
title: Text(
l10n.deleteDrawingAndMessage,
style: const TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(sheetContext);
_showDeleteDrawingConfirmation(parentContext);
},
),
// Details option
ListTile(
leading: Icon(Icons.data_object),
title: Text(l10n.technicalDetails),
onTap: () {
Navigator.pop(sheetContext);
_showTechnicalDetails(parentContext);
},
),
// Delete message option
ListTile(
leading: Icon(Icons.delete, color: Colors.red),
title: Text(
l10n.delete,
style: const TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(sheetContext);
_showDeleteConfirmation(parentContext);
},
),
],
),
),
);
}
void _showTechnicalDetails(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>();
final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf;
final radioCr = connectionProvider.deviceInfo.radioCr;
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final voiceProvider = context.read<VoiceProvider>();
final imageProvider = context.read<ip.ImageProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
widget.message.isSentMessage ||
widget.message.isFromSelf(selfPublicKey);
String? senderName;
Contact? senderContact;
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('');
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 storedLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id,
);
final receptionDetails = messagesProvider.getMessageReceptionDetails(
widget.message.id,
);
final transferDetails = messagesProvider.getMessageTransferDetails(
widget.message.id,
);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final voiceSession = widget.message.voiceId != null
? voiceProvider.session(widget.message.voiceId!)
: null;
final imageEnvelope = ImageEnvelope.tryParse(widget.message.text);
final imageSession = imageEnvelope != null
? imageProvider.session(imageEnvelope.sessionId)
: null;
final imageTxEstimate = imageEnvelope != null
? estimateImageTransmitDuration(
fragmentCount: imageEnvelope.total,
sizeBytes: imageEnvelope.sizeBytes,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: Duration.zero;
final voiceTxEstimate = voiceSession != null
? estimateVoiceTransmitDurationFromPackets(
packets: voiceSession.packets,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: envelope != null
? estimateVoiceTransmitDuration(
mode: envelope.mode,
packetCount: envelope.total,
durationMs: envelope.durationMs,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: Duration.zero;
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 matchedRxLog = _findBestMatchingRxLog(
connectionProvider.bleService.packetLogs,
widget.message,
);
final packetPathBytes = _extractPathBytesFromLog(matchedRxLog);
final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes)
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
final lastEchoRelayHash = receptionDetails?.pathBytes?.isNotEmpty == true
? receptionDetails!.pathBytes!.last
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase()
: null;
final lastEchoBytesReport = _formatPathBytesReport(
receptionDetails?.pathBytes,
);
final snrDb =
receptionDetails?.snrDb ??
matchedRxLog?.logRxDataInfo?.snrDb ??
(widget.message.lastEchoSnrRaw != null
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
: null);
final rssiDbm =
receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ??
widget.message.lastEchoRssiDbm;
final retryCause = _retryCauseLabel(widget.message);
final retryResult = _retryResultLabel(widget.message);
final retryMode = _retryModeLabel(widget.message);
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(widget.message.id);
final messageLocationSnapshot = _resolveDetailsLocationSnapshot(
storedSnapshot: storedLocationSnapshot,
senderContact: senderContact,
connectionProvider: connectionProvider,
isOwnMessage: isOwnMessage,
);
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): ${_hopDebugLabel(widget.message)}',
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
'Channel index: ${widget.message.channelIdx ?? '-'}',
'Echo count: ${widget.message.echoCount}',
'Last echo relay hash: ${lastEchoRelayHash ?? '-'}',
'Last echo path bytes: ${receptionDetails?.pathBytesHex ?? '-'}',
'Last echo bytes report: ${lastEchoBytesReport ?? '-'}',
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
'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 ?? '-'}',
'Received copies: ${widget.receivedCopies}',
'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}',
'Retry cause: ${retryCause ?? '-'}',
'Retry mode: ${retryMode ?? '-'}',
'Retry result: ${retryResult ?? '-'}',
'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'${widget.message.isSentMessage ? "Sent from location" : "Sender location at receipt"}: ${messageLocationSnapshot?.formattedCoordinates ?? '-'}',
'${widget.message.isSentMessage ? "Sent from source" : "Sender location source"}: ${messageLocationSnapshot?.technicalSourceLabel ?? '-'}',
'${widget.message.isSentMessage ? "Sent from timestamp" : "Sender location timestamp"}: ${messageLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
'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 (transferDetails != null) {
rawLines.add('Transfers served: ${transferDetails.totalTransfers}');
rawLines.add(
'Downloaded by: ${_formatDownloaderSummary(transferDetails)}',
);
}
if (widget.message.isVoice) {
rawLines.add('--- Voice Technical ---');
if (envelope != null) {
rawLines.add('Envelope format: VE3 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 ver: ${envelope.version}');
} 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)}',
);
rawLines.add(
'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)',
);
} else {
rawLines.add('Session present locally: no');
if (voiceTxEstimate > Duration.zero) {
rawLines.add(
'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)',
);
}
}
}
if (imageEnvelope != null) {
rawLines.add('--- Image Technical ---');
rawLines.add('Envelope format: IE1');
rawLines.add('Session ID: ${imageEnvelope.sessionId}');
rawLines.add(
'Image format: ${imageEnvelope.format.label} (id=${imageEnvelope.format.id})',
);
rawLines.add(
'Dimensions: ${imageEnvelope.width}×${imageEnvelope.height}',
);
rawLines.add('Fragments total (envelope): ${imageEnvelope.total}');
rawLines.add('Compressed size (envelope): ${imageEnvelope.sizeBytes} B');
rawLines.add(
'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)',
);
rawLines.add('Envelope ver: ${imageEnvelope.version}');
if (imageSession != null) {
rawLines.add('Session present locally: yes');
rawLines.add(
'Fragments received/total: ${imageSession.receivedCount}/${imageSession.total}',
);
rawLines.add('Session complete: ${imageSession.isComplete}');
final kb = (imageSession.imageBytes?.length ?? 0) / 1024.0;
rawLines.add(
'Reassembled size: ${imageSession.imageBytes != null ? '${kb.toStringAsFixed(1)} kB' : '-'}',
);
} else {
rawLines.add('Session present locally: no');
}
}
void copyField(BuildContext feedbackContext, String value) {
Clipboard.setData(ClipboardData(text: value));
ToastLogger.success(feedbackContext, l10n.textCopiedToClipboard);
}
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => SafeArea(
child: SizedBox(
height: MediaQuery.of(sheetContext).size.height * 0.85,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
l10n.messageTechnicalDetails,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
),
IconButton(
onPressed: () => Navigator.pop(sheetContext),
icon: Icon(Icons.close),
tooltip: l10n.close,
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (messageLocationSnapshot != null) ...[
_techSection(
sheetContext,
icon: Icons.location_on,
title: l10n.location,
child: Column(
children: [
_buildLocationPreviewMap(
sheetContext,
messageLocationSnapshot.location,
),
const SizedBox(height: 12),
_detailRow(
sheetContext,
label: l10n.coordinates,
value: messageLocationSnapshot
.formattedCoordinates,
onCopy: () => copyField(
sheetContext,
messageLocationSnapshot.formattedCoordinates,
),
),
_detailRow(
sheetContext,
label: l10n.source,
value: messageLocationSnapshot
.technicalSourceLabel,
),
_detailRow(
sheetContext,
label: l10n.captured,
value: _formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
onCopy: () => copyField(
sheetContext,
_formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
),
),
],
),
),
],
if (widget.message.lastEchoRssiDbm != null ||
snrDb != null ||
rssiDbm != null) ...[
SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.network_check,
title: l10n.linkQuality,
child: Column(
children: [
if (rssiDbm != null)
_signalRow(
sheetContext,
label: l10n.rssi,
valueLabel: '$rssiDbm dBm',
normalized:
((rssiDbm.toDouble() + 120.0) / 70.0)
.clamp(0.0, 1.0),
color: rssiDbm >= -80
? Colors.green
: rssiDbm >= -95
? Colors.amber
: Colors.redAccent,
),
if (snrDb != null) ...[
SizedBox(height: 8),
_signalRow(
sheetContext,
label: l10n.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,
),
],
],
),
),
],
SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.tune,
title: l10n.delivery,
child: Column(
children: [
_detailRow(
sheetContext,
label: l10n.status,
value: widget.message.deliveryStatus.name,
),
_detailRow(
sheetContext,
label: l10n.receivedRfc3339,
value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.receivedAt),
),
),
if (widget.message.expectedAckTag != null)
_detailRow(
sheetContext,
label: l10n.expectedAckTag,
value: widget.message.expectedAckTag!
.toString(),
),
if (receptionDetails?.senderToReceiptMs != null)
_detailRow(
sheetContext,
label: l10n.senderToReceipt,
value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!,
),
),
if (receptionDetails?.estimatedTransmitMs != null)
_detailRow(
sheetContext,
label: l10n.estimatedTx,
value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!,
),
),
if (receptionDetails?.postTransmitDelayMs != null)
_detailRow(
sheetContext,
label: l10n.posttxDelay,
value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!,
),
),
if (widget.receivedCopies > 1)
_detailRow(
sheetContext,
label: l10n.receivedCopies,
value: '${widget.receivedCopies}',
),
if (lastEchoRelayHash != null)
_detailRow(
sheetContext,
label: l10n.lastEchoRelay,
value: lastEchoRelayHash,
onCopy: () =>
copyField(sheetContext, lastEchoRelayHash),
),
if (receptionDetails?.pathBytesHex
case final echoPath?)
_detailRow(
sheetContext,
label: l10n.lastEchoPath,
value: echoPath,
onCopy: () => copyField(sheetContext, echoPath),
),
if (lastEchoBytesReport != null)
_detailRow(
sheetContext,
label: l10n.lastEchoBytesReport,
value: lastEchoBytesReport,
onCopy: () => copyField(
sheetContext,
lastEchoBytesReport,
),
),
if (widget.message.suggestedTimeoutMs != null)
_detailRow(
sheetContext,
label: l10n.ackTimeout,
value:
'${widget.message.suggestedTimeoutMs} ms',
),
if (retryCause != null)
_detailRow(
sheetContext,
label: l10n.retryCause,
value: retryCause,
),
if (retryMode != null)
_detailRow(
sheetContext,
label: l10n.retryMode,
value: retryMode,
),
if (widget.message.roundTripTimeMs != null)
_detailRow(
sheetContext,
label: l10n.roundTrip,
value: '${widget.message.roundTripTimeMs} ms',
),
if (widget.message.retryAttempt > 0)
_detailRow(
sheetContext,
label: l10n.retryAttempt,
value: '${widget.message.retryAttempt}/4',
),
if (widget.message.lastRetryAt != null)
_detailRow(
sheetContext,
label: l10n.lastRetry,
value: _formatRfc3339(
widget.message.lastRetryAt!,
),
onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.lastRetryAt!),
),
),
if (widget.message.usedFloodFallback)
_detailRow(
sheetContext,
label: l10n.floodFallback,
value: l10n.yes,
),
if (routeMetadata?.canonicalPath
case final routePath?)
_detailRow(
sheetContext,
label: l10n.selectedPath,
value: routePath,
onCopy: () =>
copyField(sheetContext, routePath),
),
if (retryResult != null)
_detailRow(
sheetContext,
label: l10n.retryResult,
value: retryResult,
),
if (packetPathHex != null)
_detailRow(
sheetContext,
label: l10n.pathBytes,
value: packetPathHex,
onCopy: () =>
copyField(sheetContext, packetPathHex),
),
],
),
),
SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.badge,
title: l10n.identity,
child: Column(
children: [
_detailRow(
sheetContext,
label: l10n.messageId,
value: widget.message.id,
onCopy: () =>
copyField(sheetContext, widget.message.id),
),
_detailRow(
sheetContext,
label: l10n.sender,
value:
senderName ??
widget.message.senderName ??
'Unknown',
),
if (senderPrefixHex != null)
_detailRow(
sheetContext,
label: l10n.senderKey,
value: senderPrefixHex,
onCopy: () =>
copyField(sheetContext, senderPrefixHex),
),
if (recipientName != null)
_detailRow(
sheetContext,
label: l10n.recipient,
value: recipientName,
),
if (recipientPrefixHex != null)
_detailRow(
sheetContext,
label: l10n.recipientKey,
value: recipientPrefixHex,
onCopy: () =>
copyField(sheetContext, recipientPrefixHex),
),
],
),
),
if (widget.message.isVoice) ...[
SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.graphic_eq,
title: l10n.voice,
child: Column(
children: [
_detailRow(
sheetContext,
label: l10n.voiceId,
value: widget.message.voiceId ?? '-',
),
_detailRow(
sheetContext,
label: l10n.envelope,
value: envelope != null
? 'VE3 compact'
: l10n.unknown,
),
if (voiceSession != null)
_detailRow(
sheetContext,
label: l10n.sessionProgress,
value:
'${voiceSession.receivedCount}/${voiceSession.total} segments',
),
if (voiceSession != null)
_detailRow(
sheetContext,
label: l10n.complete,
value: voiceSession.isComplete
? l10n.yes
: l10n.no,
),
if (transferDetails != null)
_detailRow(
sheetContext,
label: l10n.transfers,
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
sheetContext,
label: l10n.downloadedBy,
value: _formatDownloaderSummary(
transferDetails,
),
),
if (voiceTxEstimate > Duration.zero)
_detailRow(
sheetContext,
label: l10n.estimatedTx,
value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
),
],
),
),
],
if (imageEnvelope != null) ...[
SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.image_outlined,
title: l10n.image,
child: Column(
children: [
_detailRow(
sheetContext,
label: l10n.envelope,
value: 'IE1',
),
_detailRow(
sheetContext,
label: l10n.format,
value: imageEnvelope.format.label,
),
_detailRow(
sheetContext,
label: l10n.dimensions,
value:
'${imageEnvelope.width}×${imageEnvelope.height}',
),
_detailRow(
sheetContext,
label: l10n.segments,
value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}'
: '${imageEnvelope.total}',
),
if (imageSession != null)
_detailRow(
sheetContext,
label: l10n.complete,
value: imageSession.isComplete
? l10n.yes
: l10n.no,
),
if (transferDetails != null)
_detailRow(
sheetContext,
label: l10n.transfers,
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
sheetContext,
label: l10n.downloadedBy,
value: _formatDownloaderSummary(
transferDetails,
),
),
if (imageTxEstimate > Duration.zero)
_detailRow(
sheetContext,
label: l10n.estimatedTx,
value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
),
],
),
),
],
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: Text(
l10n.rawDump,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(sheetContext)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
rawLines.join('\n'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
],
),
),
),
],
),
),
),
);
}
Widget _buildLocationPreviewMap(BuildContext context, LatLng location) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 180,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCenter: location,
initialZoom: 15,
interactionOptions: const flutter_map.InteractionOptions(
flags: flutter_map.InteractiveFlag.none,
),
),
children: [
flutter_map.TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
flutter_map.MarkerLayer(
markers: [
flutter_map.Marker(
point: location,
width: 40,
height: 40,
child: Icon(
widget.message.isSentMessage
? Icons.near_me
: Icons.location_on,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
),
],
),
],
),
),
),
);
}
MessageContactLocation? _resolveDetailsLocationSnapshot({
required MessageContactLocation? storedSnapshot,
required Contact? senderContact,
required ConnectionProvider connectionProvider,
required bool isOwnMessage,
}) {
if (storedSnapshot != null) {
return storedSnapshot;
}
if (isOwnMessage) {
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (advLat != null && advLon != null && (advLat != 0 || advLon != 0)) {
return MessageContactLocation(
location: LatLng(advLat / 1e6, advLon / 1e6),
source: 'shared',
capturedAt: widget.message.sentAt,
sourceTimestamp: widget.message.sentAt,
);
}
final currentPosition = LocationTrackingService().currentPosition;
if (currentPosition != null) {
return MessageContactLocation(
location: LatLng(currentPosition.latitude, currentPosition.longitude),
source: 'shared',
capturedAt: currentPosition.timestamp,
sourceTimestamp: currentPosition.timestamp,
);
}
}
final contactLocation = senderContact?.displayLocation;
if (contactLocation != null) {
final source = senderContact?.telemetry?.gpsLocation != null
? 'telemetry'
: 'advert';
return MessageContactLocation(
location: contactLocation,
source: source,
capturedAt:
senderContact?.locationUpdateTime ?? widget.message.receivedAt,
sourceTimestamp: senderContact?.locationUpdateTime,
);
}
return null;
}
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 _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',
),
],
),
);
}
String _formatDownloaderSummary(MessageTransferDetails transferDetails) {
return transferDetails.downloaders.map(_formatDownloaderLabel).join(', ');
}
String _formatDownloaderLabel(MessageTransferDownloader downloader) {
final name = downloader.requesterName?.trim();
final base = name != null && name.isNotEmpty
? '$name (${downloader.requesterKey6})'
: downloader.requesterKey6;
return downloader.transferCount > 1
? '$base ×${downloader.transferCount}'
: base;
}
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),
),
),
],
);
}
String _formatRfc3339(DateTime dateTime) {
final utc = dateTime.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
String four(int v) => v.toString().padLeft(4, '0');
final fraction = utc.millisecond == 0
? ''
: '.${utc.millisecond.toString().padLeft(3, '0')}';
return '${four(utc.year)}-${two(utc.month)}-${two(utc.day)}'
'T${two(utc.hour)}:${two(utc.minute)}:${two(utc.second)}'
'${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';
}
String? _formatPathBytesReport(List<int>? pathBytes) {
if (pathBytes == null || pathBytes.isEmpty) {
return null;
}
final byteHex = pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.toList();
final indexedHops = byteHex
.asMap()
.entries
.map((entry) => '#${entry.key + 1}=${entry.value}')
.join(', ');
final byteLabel = pathBytes.length == 1 ? 'byte' : 'bytes';
return '${pathBytes.length} $byteLabel [${byteHex.join(' ')}] • hops $indexedHops';
}
BlePacketLog? _findBestMatchingRxLog(
List<BlePacketLog> logs,
Message message,
) {
if (message.pathLen < 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog;
var bestDeltaMs = 999999999;
for (final log in logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue;
// Logged frame format:
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestLog = log;
}
}
if (bestDeltaMs > 30000) return null;
return bestLog;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) return null;
return decoded.pathBytes;
}
void _showDeleteConfirmation(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteMessage),
content: Text(
widget.message.isDrawing && widget.message.drawingId != null
? '${l10n.deleteMessageConfirmation} This will also remove the linked drawing from the map.'
: l10n.deleteMessageConfirmation,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
final messagesProvider = context.read<MessagesProvider>();
messagesProvider.deleteMessage(widget.message.id);
// Also delete the drawing if this is a drawing message
if (widget.message.isDrawing &&
widget.message.drawingId != null) {
final drawingProvider = context.read<DrawingProvider>();
drawingProvider.removeDrawing(widget.message.drawingId!);
}
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.delete),
),
],
),
);
}
void _shareLocation(BuildContext context) {
if (widget.message.sarGpsCoordinates == null) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.noGpsCoordinatesAvailable,
);
return;
}
final coords = widget.message.sarGpsCoordinates!;
// Get SAR marker type emoji/name
String markerInfo = '';
if (widget.message.sarMarkerType != null) {
markerInfo = widget.message.sarMarkerType!.emoji;
if (widget.message.sarNotes != null &&
widget.message.sarNotes!.isNotEmpty) {
markerInfo += ' ${widget.message.sarNotes}';
}
} else if (widget.message.sarCustomEmoji != null) {
markerInfo = widget.message.sarCustomEmoji!;
if (widget.message.sarNotes != null &&
widget.message.sarNotes!.isNotEmpty) {
markerInfo += ' ${widget.message.sarNotes}';
}
}
// Format coordinates with 6 decimal places (≈0.1m precision)
final lat = coords.latitude.toStringAsFixed(6);
final lon = coords.longitude.toStringAsFixed(6);
// Build share text
final shareText = AppLocalizations.of(context)!.shareLocationText(
markerInfo,
lat,
lon,
'https://www.google.com/maps/search/?api=1&query=$lat,$lon',
);
// Share the location
SharePlus.instance.share(
ShareParams(
text: shareText,
subject: AppLocalizations.of(context)!.sarLocationShare,
),
);
}
Future<void> _saveAsTemplate(BuildContext context) async {
if (!widget.message.isSarMarker) {
ToastLogger.error(context, AppLocalizations.of(context)!.notASarMarker);
return;
}
try {
// Parse the SAR message to create a template
final template = SarTemplate.fromSarMessage(widget.message.text);
// Get SAR template service
final sarTemplateService = SarTemplateService();
// Check if template with this emoji already exists
final existingTemplates = sarTemplateService.templates
.where((t) => t.emoji == template.emoji)
.toList();
if (existingTemplates.isNotEmpty) {
if (!context.mounted) return;
ToastLogger.warning(
context,
AppLocalizations.of(context)!.templateAlreadyExists,
);
return;
}
// Save the template
await sarTemplateService.addTemplate(template);
if (!context.mounted) return;
ToastLogger.success(context, AppLocalizations.of(context)!.templateSaved);
} catch (e) {
debugPrint('Error saving template: $e');
if (!context.mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSaveTemplate('$e'),
);
}
}
void _navigateToDrawing(BuildContext context) {
if (widget.message.drawingId == null) return;
widget.onTap?.call();
}
void _copyDrawingCoordinates(BuildContext context) {
if (widget.message.drawingId == null) return;
final drawingProvider = context.read<DrawingProvider>();
final drawing = drawingProvider.getDrawingById(widget.message.drawingId!);
if (drawing == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.drawingNotFound);
return;
}
// Format coordinates based on drawing type
String formatPoint(LatLng point) {
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
return '${point.latitude.toStringAsFixed(0)}, ${point.longitude.toStringAsFixed(0)}';
}
return '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}';
}
String coordinatesText;
if (drawing is LineDrawing) {
coordinatesText = drawing.points.map(formatPoint).join('\n');
} else if (drawing is RectangleDrawing) {
coordinatesText = drawing.corners.map(formatPoint).join('\n');
} else {
ToastLogger.error(
context,
AppLocalizations.of(context)!.unknownDrawingType,
);
return;
}
Clipboard.setData(ClipboardData(text: coordinatesText));
ToastLogger.success(
context,
AppLocalizations.of(context)!.textCopiedToClipboard,
);
}
void _showDeleteDrawingConfirmation(BuildContext context) {
if (widget.message.drawingId == null) return;
final l10n = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteDrawing),
content: Text(
'${l10n.deleteMessageConfirmation} This will also remove the drawing from the map.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
final drawingProvider = context.read<DrawingProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Remove the drawing from map and delete the message
drawingProvider.removeDrawingAndMessage(
widget.message.drawingId!,
messagesProvider,
);
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.delete),
),
],
),
);
}
Color _getMessageBubbleColor(
BuildContext context,
bool isOwnMessage,
bool isDarkMode,
) {
if (isOwnMessage) {
// Own messages: slightly highlighted with primary color tint
return isDarkMode
? Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.3)
: Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.15);
} else {
// Others' messages: default surface color
return Theme.of(context).colorScheme.surfaceContainerHighest;
}
}
Color _getSarMarkerColor(BuildContext context, bool isDarkMode) {
if (widget.message.sarMarkerType == null) {
return Theme.of(context).colorScheme.primaryContainer;
}
// Use type-specific colors with alpha for background
switch (widget.message.sarMarkerType!) {
case SarMarkerType.foundPerson:
return isDarkMode
? const Color(0xFF1B5E20).withValues(alpha: 0.4)
: const Color(0xFFC8E6C9).withValues(alpha: 0.9);
case SarMarkerType.fire:
return isDarkMode
? const Color(0xFFB71C1C).withValues(alpha: 0.4)
: const Color(0xFFFFCDD2).withValues(alpha: 0.9);
case SarMarkerType.stagingArea:
return isDarkMode
? const Color(0xFF0D47A1).withValues(alpha: 0.4)
: const Color(0xFFBBDEFB).withValues(alpha: 0.9);
case SarMarkerType.object:
return isDarkMode
? const Color(0xFF4A148C).withValues(alpha: 0.4)
: const Color(0xFFE1BEE7).withValues(alpha: 0.9);
case SarMarkerType.unknown:
return isDarkMode
? const Color(0xFF424242).withValues(alpha: 0.4)
: const Color(0xFFEEEEEE).withValues(alpha: 0.9);
}
}
Color _getSarMarkerBorderColor(BuildContext context, bool isDarkMode) {
if (widget.message.sarMarkerType == null) {
return Theme.of(context).colorScheme.primary;
}
// Use vibrant type-specific colors for borders
switch (widget.message.sarMarkerType!) {
case SarMarkerType.foundPerson:
return const Color(0xFF4CAF50); // Green
case SarMarkerType.fire:
return const Color(0xFFF44336); // Red
case SarMarkerType.stagingArea:
return const Color(0xFF2196F3); // Blue
case SarMarkerType.object:
return const Color(0xFF9C27B0); // Purple
case SarMarkerType.unknown:
return const Color(0xFF9E9E9E); // Gray
}
}
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 '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
String? _retryCauseLabel(Message message) {
if (!message.isContactMessage || message.expectedAckTag == null) {
return null;
}
if (message.deliveryStatus == MessageDeliveryStatus.sending &&
message.retryAttempt == 0) {
return 'Waiting for delivery ACK';
}
if (message.retryAttempt > 0 || message.usedFloodFallback) {
return 'Delivery ACK timeout';
}
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
return 'Delivery confirmation not received';
}
return null;
}
String? _retryModeLabel(Message message) {
if (!message.isContactMessage) {
return null;
}
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
if (routeMetadata != null) {
return routeMetadata.modeLabel;
}
if (message.usedFloodFallback) {
return 'Flood route';
}
if (message.retryAttempt > 0 || message.expectedAckTag != null) {
return 'Learned direct path';
}
return null;
}
String? _retryResultLabel(Message message) {
if (!message.isContactMessage) {
return null;
}
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
final routeLabel = routeMetadata?.modeLabel.toLowerCase();
if (message.deliveryStatus == MessageDeliveryStatus.delivered) {
if (routeLabel != null) {
return 'Delivered via $routeLabel';
}
if (message.usedFloodFallback) {
return 'Delivered after flood route';
}
if (message.retryAttempt > 0) {
return 'Delivered after retry';
}
if (message.expectedAckTag != null) {
return 'Delivery confirmed';
}
}
if (message.deliveryStatus == MessageDeliveryStatus.sending) {
if (routeLabel != null) {
return '$routeLabel in progress';
}
if (message.usedFloodFallback) {
return 'Flood route in progress';
}
if (message.retryAttempt > 0) {
return 'Retry in progress';
}
if (message.expectedAckTag != null) {
return 'Awaiting confirmation';
}
}
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
if (routeLabel != null) {
return 'Failed via $routeLabel';
}
if (message.usedFloodFallback) {
return 'Failed after flood route';
}
if (message.retryAttempt > 0) {
return 'Failed after retry attempts';
}
return 'Delivery failed';
}
return null;
}
Widget _buildDirectMessageStatusIndicator(
BuildContext context,
Message message,
) {
switch (message.deliveryStatus) {
case MessageDeliveryStatus.delivered:
return Icon(
Icons.done_all,
size: 16,
color: Theme.of(context).colorScheme.primary,
);
case MessageDeliveryStatus.sent:
return Icon(
Icons.done,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
case MessageDeliveryStatus.sending:
return Icon(Icons.schedule, size: 15, color: Colors.orange);
case MessageDeliveryStatus.failed:
return const Icon(Icons.error_outline, size: 15, color: Colors.red);
case MessageDeliveryStatus.received:
return Icon(
Icons.done_all,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
}
@override
Widget build(BuildContext context) {
// Display system messages with minimal styling
if (widget.message.isSystemMessage) {
return SystemMessageBubble(message: widget.message);
}
final message = widget.message;
final ticTacToeEvent = message.isContactMessage
? TicTacToeMessageParser.tryParse(message.text)
: null;
if (ticTacToeEvent?.type == TicTacToeEventType.move) {
// Hide move control packets from chat; the game bubble updates itself.
return const SizedBox.shrink();
}
final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final messageFontScale = context.watch<AppProvider>().messageFontScale;
final l10n = AppLocalizations.of(context)!;
// Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final channelsProvider = context.watch<ChannelsProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey);
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
// Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>();
dynamic senderContact;
if (message.senderPublicKeyPrefix != null && !isOwnMessage) {
final senderKeyHex = message.senderPublicKeyPrefix!
.sublist(
0,
message.senderPublicKeyPrefix!.length < 6
? message.senderPublicKeyPrefix!.length
: 6,
)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
senderContact = contactsProvider.contacts.where((c) {
return c.publicKeyHex.startsWith(senderKeyHex);
}).firstOrNull;
}
// Get rich display name (with emoji if available)
final displayName = isOwnMessage
? l10n.you
: message.getRichDisplayName(senderContact);
// Look up destination/source display labels for direct/channel messages
dynamic recipientContact;
String? recipientDisplayName;
String? channelDisplayName;
if (isOwnMessage &&
message.isContactMessage &&
message.recipientPublicKey != null) {
final recipientKeyHex = message.recipientPublicKey!
.sublist(
0,
message.recipientPublicKey!.length < 6
? message.recipientPublicKey!.length
: 6,
)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
recipientContact = contactsProvider.contacts.where((c) {
final matches = c.publicKeyHex.startsWith(recipientKeyHex);
return matches;
}).firstOrNull;
if (recipientContact != null) {
final roleEmoji = recipientContact.roleEmoji;
if (roleEmoji != null && roleEmoji.isNotEmpty) {
recipientDisplayName = '$roleEmoji ${recipientContact.displayName}';
} else {
recipientDisplayName =
recipientContact.displayName ?? recipientContact.advName;
}
}
} else if (message.isChannelMessage) {
final channelIdx = message.channelIdx ?? 0;
if (channelIdx == 0) {
channelDisplayName = l10n.publicChannel;
} else {
final channelContact = contactsProvider.channels.where((c) {
return c.publicKey.length > 1 && c.publicKey[1] == channelIdx;
}).firstOrNull;
final syncedChannel = channelsProvider.getChannel(channelIdx);
final syncedChannelDisplayName =
syncedChannel != null && syncedChannel.hasCustomName
? syncedChannel.displayName
: null;
final contactChannelDisplayName = channelContact
?.getLocalizedDisplayName(context)
.trim();
channelDisplayName =
syncedChannelDisplayName ??
(contactChannelDisplayName != null &&
contactChannelDisplayName.isNotEmpty
? contactChannelDisplayName
: null) ??
syncedChannel?.displayName ??
'${l10n.channel} $channelIdx';
}
if (isOwnMessage) {
recipientDisplayName = channelDisplayName;
}
}
final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName;
final directCounterpartLabel = !message.isChannelMessage
? (isOwnMessage ? recipientSubtitle : l10n.you)
: null;
final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${l10n.channel}: $channelDisplayName'
: null;
final shouldFloatBubble = widget.isCompact;
final baseBodyStyle = Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontSize: 14 * messageFontScale);
final bubble = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: shouldFloatBubble
? MediaQuery.of(context).size.width * 0.78
: double.infinity,
),
child: GestureDetector(
onTap: () => _handleBubbleTap(
isSarMarker: isSarMarker,
isDrawing: message.isDrawing,
),
onDoubleTap: widget.isCompact
? null
: () => _showTechnicalDetails(context),
onLongPress: widget.isCompact
? null
: () => _showMessageOptions(context),
child: Container(
margin: EdgeInsets.zero,
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.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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header badge for drawing messages
if (message.isDrawing)
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.draw, size: 16, color: Colors.white),
SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.mapDrawing,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
),
),
],
),
// Sender info row (shown for all messages)
Row(
children: [
if (isSarMarker) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: _getSarMarkerBorderColor(context, isDarkMode),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.warning_amber_rounded,
size: 16,
color: Colors.white,
),
SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.sarAlert,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
),
],
),
),
const SizedBox(width: 8),
],
// Unread indicator badge (only for regular messages, not SAR/drawing)
if (!message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage &&
!isSarMarker &&
!message.isDrawing)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
buildMessageHeaderAvatar(
context,
isOwnMessage: isOwnMessage,
isChannelMessage: message.isChannelMessage,
senderContact: senderContact,
displayName: displayName,
),
const SizedBox(width: 4),
Expanded(
child: Row(
children: [
Expanded(
child: Text(
displayName,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.1,
color: isOwnMessage
? Theme.of(context).colorScheme.primary
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (!widget.isCompact &&
(recipientSubtitle != null ||
directCounterpartLabel != null ||
receivedChannelSubtitle != null)) ...[
const SizedBox(width: 8),
if (message.isChannelMessage)
Align(
alignment: Alignment.centerRight,
child: buildChannelHeaderPill(
context,
label: isOwnMessage
? recipientDisplayName!
: channelDisplayName!,
),
)
else
Align(
alignment: Alignment.centerRight,
child: buildDirectHeaderCounterpart(
context,
label: directCounterpartLabel!,
),
),
],
],
),
),
],
),
const SizedBox(height: 8),
// SAR marker content
if (isSarMarker && message.sarMarkerType != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(
alpha: isDarkMode ? 0.06 : 0.42,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: _getSarMarkerBorderColor(
context,
isDarkMode,
).withValues(alpha: 0.22),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: _getSarMarkerBorderColor(
context,
isDarkMode,
).withValues(alpha: isDarkMode ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Text(
message.sarCustomEmoji ??
message.sarMarkerType!.emoji,
style: const TextStyle(fontSize: 30, height: 1),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.sarMarkerType!.getLocalizedName(
context,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
),
if (message.sarNotes != null &&
message.sarNotes!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
message.sarNotes!,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
height: 1.25,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.86),
),
),
],
],
),
),
if (!widget.isCompact)
Padding(
padding: const EdgeInsets.only(left: 8, top: 2),
child: Icon(
Icons.chevron_right_rounded,
size: 20,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
),
],
),
if (message.sarGpsCoordinates != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: isDarkMode ? 0.18 : 0.05,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.place_outlined,
size: 15,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.tag_rounded,
size: 15,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
formatPlusCode(
message.sarGpsCoordinates!.latitude,
message.sarGpsCoordinates!.longitude,
),
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
),
],
),
],
),
),
] else if (message.sarCustomMapPoint != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: isDarkMode ? 0.18 : 0.05,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.map_outlined,
size: 15,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
AppLocalizations.of(
context,
)!.customMapMarker,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 6),
Text(
AppLocalizations.of(
context,
)!.customMapPointLabel(
'${message.sarCustomMapPoint!.latitude.toStringAsFixed(0)}, ${message.sarCustomMapPoint!.longitude.toStringAsFixed(0)}',
),
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
if (message.sarCustomMapId != null &&
message.sarCustomMapId!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
AppLocalizations.of(
context,
)!.mapIdLabel(message.sarCustomMapId!),
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
],
],
),
),
],
],
),
),
]
// Drawing message content (skip in compact mode - drawings hidden)
else if (message.isDrawing &&
message.drawingId != null &&
!widget.isCompact)
Consumer<DrawingProvider>(
builder: (context, drawingProvider, child) {
final drawing = drawingProvider.getDrawingById(
message.drawingId!,
);
if (drawing == null) {
return _buildMessageTextContent(
message.text,
baseBodyStyle,
);
}
final String drawingTypeLabel;
if (drawing is LineDrawing) {
drawingTypeLabel = AppLocalizations.of(
context,
)!.lineDrawing;
} else if (drawing is RectangleDrawing) {
drawingTypeLabel = AppLocalizations.of(
context,
)!.rectangleDrawing;
} else {
drawingTypeLabel = AppLocalizations.of(context)!.drawing;
}
final colorName = DrawingColors.colorToName(drawing.color);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DrawingMinimapPreview(drawing: drawing),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drawingTypeLabel,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: drawing.color,
shape: BoxShape.circle,
border: Border.all(
color: Colors.black26,
width: 1,
),
),
),
const SizedBox(width: 6),
Text(
colorName,
style: Theme.of(
context,
).textTheme.labelSmall,
),
],
),
],
),
),
Icon(
Icons.chevron_right,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
],
);
},
)
// Voice message content
else if (message.isVoice &&
message.voiceId != null &&
!widget.isCompact)
VoiceMessageBubble(message: message, isSentByMe: isOwnMessage)
// Image message content (IE1 envelope)
else if (ImageEnvelope.isEnvelope(message.text) &&
!widget.isCompact)
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
// Tic-Tac-Toe control message content
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
!widget.isCompact)
TicTacToeMessageBubble(
message: message,
isSentByMe: isOwnMessage,
)
// Regular message content
else if (!message.isDrawing || widget.isCompact)
_buildMessageTextContent(message.text, baseBodyStyle),
// Delivery status for sent messages (skip in compact mode)
if (message.isSentMessage && !widget.isCompact) ...[
const SizedBox(height: 6),
// Debug: Log grouped message detection
Builder(
builder: (context) {
if (message.isGroupedMessage) {
debugPrint(
'🎯 [MessageBubble] Rendering grouped message: ${message.id}',
);
debugPrint(
' Recipients: ${message.recipients?.length ?? 0}',
);
debugPrint(
' Delivered: ${message.deliveredRecipientsCount}',
);
debugPrint(' Failed: ${message.failedRecipientsCount}');
}
return const SizedBox.shrink();
},
),
// Show grouped message delivery count
if (message.isGroupedMessage) ...[
GestureDetector(
onTap: _toggleExpanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
message.deliveredRecipientsCount ==
message.recipients!.length
? Icons.done_all
: message.failedRecipientsCount > 0
? Icons.error_outline
: Icons.schedule,
size: 12,
color:
message.deliveredRecipientsCount ==
message.recipients!.length
? Colors.green
: message.failedRecipientsCount > 0
? Colors.red
: Colors.orange,
),
SizedBox(width: 3),
Text(
message.deliveredRecipientsCount ==
message.recipients!.length
? AppLocalizations.of(context)!.allDelivered
: AppLocalizations.of(
context,
)!.deliveredToContacts(
message.deliveredRecipientsCount,
message.recipients!.length,
),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color:
message.deliveredRecipientsCount ==
message.recipients!.length
? Colors.green
: message.failedRecipientsCount > 0
? Colors.red
: Colors.orange,
fontStyle: FontStyle.italic,
),
),
const SizedBox(width: 4),
Icon(
_isExpanded
? Icons.expand_less
: Icons.expand_more,
size: 14,
color: Theme.of(
context,
).textTheme.labelSmall?.color,
),
],
),
// Expandable recipient details
if (_isExpanded) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(
context,
)!.recipientDetails,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
...message.recipients!.map((recipient) {
final Color statusColor;
final IconData statusIcon;
final String statusText;
switch (recipient.deliveryStatus) {
case MessageDeliveryStatus.delivered:
statusColor = Colors.green;
statusIcon = Icons.done_all;
statusText =
recipient.roundTripTimeMs != null
? '${recipient.roundTripTimeMs}ms'
: AppLocalizations.of(
context,
)!.delivered;
break;
case MessageDeliveryStatus.sent:
statusColor = Theme.of(
context,
).colorScheme.onSurfaceVariant;
statusIcon = Icons.done;
statusText = AppLocalizations.of(
context,
)!.sent;
break;
case MessageDeliveryStatus.failed:
statusColor = Colors.red;
statusIcon = Icons.cancel;
statusText = AppLocalizations.of(
context,
)!.failed;
break;
case MessageDeliveryStatus.sending:
default:
statusColor = Colors.orange;
statusIcon = Icons.schedule;
statusText = AppLocalizations.of(
context,
)!.pending;
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(
statusIcon,
size: 14,
color: statusColor,
),
const SizedBox(width: 6),
Expanded(
child: Text(
recipient.displayName,
style: Theme.of(
context,
).textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
),
Text(
statusText,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
],
),
);
}),
],
),
),
],
],
),
),
]
// Show single message delivery status
// Channel messages only show transient states (sending/failed);
// delivered/ACK states don't exist for channels
else if (!message.isChannelMessage ||
message.deliveryStatus == MessageDeliveryStatus.sending ||
message.deliveryStatus == MessageDeliveryStatus.failed)
Row(
mainAxisSize: MainAxisSize.max,
children: [
if (message.isContactMessage)
_buildDirectMessageStatusIndicator(context, message)
else ...[
Icon(
getDeliveryStatusIcon(message.deliveryStatus),
size: 12,
color: getDeliveryStatusColor(message.deliveryStatus),
),
const SizedBox(width: 3),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: 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
if (message.deliveryStatus ==
MessageDeliveryStatus.failed) ...[
const SizedBox(width: 6),
GestureDetector(
onTap: () => _retryFailedMessage(context, message),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.orange,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.refresh,
size: 12,
color: Colors.orange,
),
const SizedBox(width: 4),
Text(
'Retry',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
],
),
],
],
),
),
),
);
final bubbleWithMeta = Column(
crossAxisAlignment: isOwnMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
bubble,
buildBubbleMetaFooter(
context,
message: message,
isSarMarker: isSarMarker,
routeMetadata: routeMetadata,
),
],
);
if (!shouldFloatBubble) {
return bubbleWithMeta;
}
return Row(
mainAxisAlignment: isOwnMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [Flexible(child: bubbleWithMeta)],
);
}
}