mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Refactor messages tab UI
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
|
||||
/// Reusable location display widget with tap-to-show modal
|
||||
/// Shows coordinates in a compact format with ability to view all formats
|
||||
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
Text(
|
||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
// Non-compact version (just text)
|
||||
return Text(
|
||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
|
||||
children: [
|
||||
const Text(
|
||||
'Location Formats',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
|
||||
_buildFormatRow(
|
||||
context,
|
||||
'Plus Code',
|
||||
_convertToPlusCode(location.latitude, location.longitude),
|
||||
formatPlusCode(location.latitude, location.longitude),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.copiedToClipboard(label),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
|
||||
// Full MGRS would require UTM conversion library
|
||||
return '$zone$letter (approximate)';
|
||||
}
|
||||
|
||||
/// Convert to Google Plus Code format
|
||||
/// Simplified implementation - returns approximate code
|
||||
String _convertToPlusCode(double lat, double lon) {
|
||||
// This is a simplified version - full Plus Code requires the open_location_code package
|
||||
const base = '23456789CFGHJMPQRVWX';
|
||||
|
||||
// Normalize coordinates
|
||||
lat = (lat + 90) / 180; // 0 to 1
|
||||
lon = (lon + 180) / 360; // 0 to 1
|
||||
|
||||
String code = '';
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if (i == 4) code += '+';
|
||||
|
||||
int latDigit = (lat * 20).floor() % 20;
|
||||
int lonDigit = (lon * 20).floor() % 20;
|
||||
|
||||
code += base[latDigit];
|
||||
code += base[lonDigit];
|
||||
|
||||
lat = (lat * 20) % 1;
|
||||
lon = (lon * 20) % 1;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../providers/map_provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import 'direct_message_sheet.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
import '../../utils/battery_display_helper.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
@@ -827,7 +828,7 @@ class ContactTile extends StatelessWidget {
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'Plus Code',
|
||||
_convertToPlusCode(
|
||||
formatPlusCode(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
@@ -1113,34 +1114,6 @@ class ContactTile extends StatelessWidget {
|
||||
return '$zone$letter (approximate)';
|
||||
}
|
||||
|
||||
/// Convert to Google Plus Code format
|
||||
/// Simplified implementation - returns approximate code
|
||||
String _convertToPlusCode(double lat, double lon) {
|
||||
// This is a simplified version - full Plus Code requires the open_location_code package
|
||||
// For now, return a placeholder that shows it's not fully implemented
|
||||
const base = '23456789CFGHJMPQRVWX';
|
||||
|
||||
// Normalize coordinates
|
||||
lat = (lat + 90) / 180; // 0 to 1
|
||||
lon = (lon + 180) / 360; // 0 to 1
|
||||
|
||||
String code = '';
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if (i == 4) code += '+';
|
||||
|
||||
int latDigit = (lat * 20).floor() % 20;
|
||||
int lonDigit = (lon * 20).floor() % 20;
|
||||
|
||||
code += base[latDigit];
|
||||
code += base[lonDigit];
|
||||
|
||||
lat = (lat * 20) % 1;
|
||||
lon = (lon * 20) % 1;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(ContactType type) {
|
||||
switch (type) {
|
||||
case ContactType.chat:
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../../utils/voice_message_parser.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
@@ -116,31 +117,16 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
Widget _buildBubbleMetaFooter(
|
||||
BuildContext context, {
|
||||
required Message message,
|
||||
required bool isOwnMessage,
|
||||
required bool isSarMarker,
|
||||
}) {
|
||||
final metaColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||
|
||||
final items = <Widget>[
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
];
|
||||
final items = <Widget>[];
|
||||
|
||||
if (!isOwnMessage && !isSarMarker && message.pathLen < 255) {
|
||||
if (!isSarMarker && message.pathLen < 255) {
|
||||
items.addAll([
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -149,9 +135,25 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
items.add(
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
||||
child: Align(
|
||||
@@ -1680,6 +1682,19 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldShowSentChannelStats(Message message) {
|
||||
if (!message.isSentMessage || !message.isChannelMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasSignalData =
|
||||
message.echoCount > 0 ||
|
||||
message.lastEchoRssiDbm != null ||
|
||||
message.lastEchoSnrRaw != null ||
|
||||
message.expectedAckTag != null;
|
||||
return _showReceivedStats && hasSignalData;
|
||||
}
|
||||
|
||||
Widget _buildReceivedSignalStatus(
|
||||
BuildContext context,
|
||||
Message message, {
|
||||
@@ -1734,6 +1749,49 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
Widget _buildChannelHeaderPill(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
}) {
|
||||
final labelColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.campaign_outlined,
|
||||
size: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _hopDebugLabel(Message message) {
|
||||
if (message.pathLen >= 255 && message.isContactMessage) {
|
||||
return 'Direct (raw: ${message.pathLen})';
|
||||
@@ -2137,78 +2195,78 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: Badge (if SAR or drawing) and time
|
||||
if (isSarMarker || message.isDrawing)
|
||||
// Header badge for drawing messages
|
||||
if (message.isDrawing)
|
||||
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,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.sarAlert,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (message.isDrawing)
|
||||
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,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.mapDrawing,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
const 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,
|
||||
),
|
||||
const 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 &&
|
||||
@@ -2255,55 +2313,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
const SizedBox(width: 8),
|
||||
if (message.isChannelMessage)
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isOwnMessage
|
||||
? Icons.campaign_outlined
|
||||
: Icons.tag,
|
||||
size: 11,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
isOwnMessage
|
||||
? recipientDisplayName!
|
||||
: channelDisplayName!,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.82),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildChannelHeaderPill(
|
||||
context,
|
||||
label: isOwnMessage
|
||||
? recipientDisplayName!
|
||||
: channelDisplayName!,
|
||||
),
|
||||
)
|
||||
else
|
||||
@@ -2407,6 +2421,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
),
|
||||
if (message.sarNotes != null &&
|
||||
message.sarNotes!.isNotEmpty) ...[
|
||||
@@ -2455,27 +2472,64 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.place_outlined,
|
||||
size: 15,
|
||||
color: _getSarMarkerBorderColor(
|
||||
context,
|
||||
isDarkMode,
|
||||
),
|
||||
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(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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -2593,6 +2647,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
if (!widget.isCompact &&
|
||||
!isSarMarker &&
|
||||
!message.isDrawing &&
|
||||
!message.isSentMessage &&
|
||||
_showReceivedStats) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildReceivedSignalStatus(
|
||||
@@ -2796,23 +2851,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
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,
|
||||
),
|
||||
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
|
||||
@@ -2858,6 +2908,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_shouldShowSentChannelStats(message)) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildChannelEchoStatus(context, message),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -2874,7 +2928,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_buildBubbleMetaFooter(
|
||||
context,
|
||||
message: message,
|
||||
isOwnMessage: isOwnMessage,
|
||||
isSarMarker: isSarMarker,
|
||||
),
|
||||
],
|
||||
|
||||
432
lib/widgets/messages/messages_composer.dart
Normal file
432
lib/widgets/messages/messages_composer.dart
Normal file
@@ -0,0 +1,432 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
class MessagesComposer extends StatelessWidget {
|
||||
final TextEditingController textController;
|
||||
final FocusNode focusNode;
|
||||
final TextInputFormatter messageByteLimiter;
|
||||
final int messageByteCount;
|
||||
final int maxMessageBytes;
|
||||
final bool isRecording;
|
||||
final bool isSendingVoice;
|
||||
final bool voiceSupported;
|
||||
final double bottomPadding;
|
||||
final String destinationLabel;
|
||||
final Widget destinationAvatar;
|
||||
final VoidCallback onShowComposerActions;
|
||||
final VoidCallback onShowRecipientSelector;
|
||||
final Future<void> Function() onStartVoiceRecording;
|
||||
final Future<void> Function() onStopAndSendVoice;
|
||||
final Future<void> Function() onSendMessage;
|
||||
|
||||
const MessagesComposer({
|
||||
super.key,
|
||||
required this.textController,
|
||||
required this.focusNode,
|
||||
required this.messageByteLimiter,
|
||||
required this.messageByteCount,
|
||||
required this.maxMessageBytes,
|
||||
required this.isRecording,
|
||||
required this.isSendingVoice,
|
||||
required this.voiceSupported,
|
||||
required this.bottomPadding,
|
||||
required this.destinationLabel,
|
||||
required this.destinationAvatar,
|
||||
required this.onShowComposerActions,
|
||||
required this.onShowRecipientSelector,
|
||||
required this.onStartVoiceRecording,
|
||||
required this.onStopAndSendVoice,
|
||||
required this.onSendMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 10, 10, bottomPadding),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_ComposerActionButton(
|
||||
isRecording: isRecording,
|
||||
onPressed: isRecording
|
||||
? onStopAndSendVoice
|
||||
: onShowComposerActions,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DestinationSelector(
|
||||
destinationLabel: destinationLabel,
|
||||
destinationAvatar: destinationAvatar,
|
||||
onTap: onShowRecipientSelector,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
textController,
|
||||
focusNode,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final canSendText =
|
||||
!isRecording &&
|
||||
!isSendingVoice &&
|
||||
textController.text.trim().isNotEmpty;
|
||||
final semanticsLabel = isRecording
|
||||
? 'Recording... release to send voice'
|
||||
: (isSendingVoice
|
||||
? 'Sending voice...'
|
||||
: voiceSupported
|
||||
? 'Send (long press to record voice)'
|
||||
: 'Send');
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MessageInput(
|
||||
textController: textController,
|
||||
focusNode: focusNode,
|
||||
messageByteLimiter: messageByteLimiter,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SendButton(
|
||||
canSendText: canSendText,
|
||||
isRecording: isRecording,
|
||||
isSendingVoice: isSendingVoice,
|
||||
voiceSupported: voiceSupported,
|
||||
semanticsLabel: semanticsLabel,
|
||||
messageByteCount: messageByteCount,
|
||||
maxMessageBytes: maxMessageBytes,
|
||||
onSendMessage: onSendMessage,
|
||||
onStartVoiceRecording: onStartVoiceRecording,
|
||||
onStopAndSendVoice: onStopAndSendVoice,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComposerActionButton extends StatelessWidget {
|
||||
final bool isRecording;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _ComposerActionButton({
|
||||
required this.isRecording,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
|
||||
tooltip: isRecording ? 'Stop recording' : 'More actions',
|
||||
onPressed: onPressed,
|
||||
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DestinationSelector extends StatelessWidget {
|
||||
final String destinationLabel;
|
||||
final Widget destinationAvatar;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DestinationSelector({
|
||||
required this.destinationLabel,
|
||||
required this.destinationAvatar,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Ink(
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
destinationAvatar,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
destinationLabel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.expand_more_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageInput extends StatelessWidget {
|
||||
final TextEditingController textController;
|
||||
final FocusNode focusNode;
|
||||
final TextInputFormatter messageByteLimiter;
|
||||
|
||||
const _MessageInput({
|
||||
required this.textController,
|
||||
required this.focusNode,
|
||||
required this.messageByteLimiter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: focusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
width: focusNode.hasFocus ? 1.4 : 1,
|
||||
),
|
||||
boxShadow: focusNode.hasFocus
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.10),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: [messageByteLimiter],
|
||||
style: const TextStyle(fontSize: 15),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
|
||||
),
|
||||
filled: false,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
),
|
||||
textInputAction: TextInputAction.newline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SendButton extends StatelessWidget {
|
||||
final bool canSendText;
|
||||
final bool isRecording;
|
||||
final bool isSendingVoice;
|
||||
final bool voiceSupported;
|
||||
final String semanticsLabel;
|
||||
final int messageByteCount;
|
||||
final int maxMessageBytes;
|
||||
final Future<void> Function() onSendMessage;
|
||||
final Future<void> Function() onStartVoiceRecording;
|
||||
final Future<void> Function() onStopAndSendVoice;
|
||||
|
||||
const _SendButton({
|
||||
required this.canSendText,
|
||||
required this.isRecording,
|
||||
required this.isSendingVoice,
|
||||
required this.voiceSupported,
|
||||
required this.semanticsLabel,
|
||||
required this.messageByteCount,
|
||||
required this.maxMessageBytes,
|
||||
required this.onSendMessage,
|
||||
required this.onStartVoiceRecording,
|
||||
required this.onStopAndSendVoice,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: canSendText || (voiceSupported && !isSendingVoice),
|
||||
label: semanticsLabel,
|
||||
onTap: canSendText ? onSendMessage : null,
|
||||
onLongPress: (voiceSupported && !isSendingVoice)
|
||||
? () {
|
||||
if (isRecording) {
|
||||
onStopAndSendVoice();
|
||||
return;
|
||||
}
|
||||
onStartVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message: semanticsLabel,
|
||||
excludeFromSemantics: true,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: canSendText ? onSendMessage : null,
|
||||
onLongPressStart: (voiceSupported && !isSendingVoice)
|
||||
? (_) => onStartVoiceRecording()
|
||||
: null,
|
||||
onLongPressEnd: (voiceSupported && isRecording)
|
||||
? (_) => onStopAndSendVoice()
|
||||
: null,
|
||||
onLongPressCancel: (voiceSupported && isRecording)
|
||||
? onStopAndSendVoice
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: canSendText || isRecording
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: canSendText || isRecording
|
||||
? Colors.transparent
|
||||
: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: canSendText || isRecording
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.22),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: isSendingVoice
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
isRecording ? Icons.mic_rounded : Icons.send_rounded,
|
||||
size: 22,
|
||||
color: canSendText || isRecording
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$messageByteCount/$maxMessageBytes',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: messageByteCount > maxMessageBytes * 0.9
|
||||
? Colors.orange.shade800
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/widgets/messages/messages_content.dart
Normal file
85
lib/widgets/messages/messages_content.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../widgets/messages/message_bubble.dart';
|
||||
|
||||
class MessagesContent extends StatelessWidget {
|
||||
final List<Message> messages;
|
||||
final ScrollController scrollController;
|
||||
final String? highlightedMessageId;
|
||||
final Future<void> Function() onRefresh;
|
||||
final VoidCallback? onNavigateToMap;
|
||||
final ValueChanged<Message>? onMessageTap;
|
||||
|
||||
const MessagesContent({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.scrollController,
|
||||
required this.highlightedMessageId,
|
||||
required this.onRefresh,
|
||||
this.onNavigateToMap,
|
||||
this.onMessageTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: messages.isEmpty
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noMessagesYet,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.pullDownToSync,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: scrollController,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
|
||||
return MessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
isHighlighted: message.id == highlightedMessageId,
|
||||
onNavigateToMap: onNavigateToMap,
|
||||
onTap: onMessageTap == null
|
||||
? null
|
||||
: () => onMessageTap!(message),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
|
||||
/// Bottom sheet for selecting message recipient (channel, contact, or room)
|
||||
class RecipientSelectorSheet extends StatefulWidget {
|
||||
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
...filteredChannels.map((channel) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.public,
|
||||
contact: channel,
|
||||
title: channel.getLocalizedDisplayName(context),
|
||||
subtitle: channel.isPublicChannel
|
||||
? l10n.broadcastToAllNearby
|
||||
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
...filteredContacts.map((contact) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.person,
|
||||
contact: contact,
|
||||
title: contact.displayName,
|
||||
subtitle: contact.publicKeyShort,
|
||||
emoji: contact.roleEmoji,
|
||||
isSelected: _isSelected('contact', contact),
|
||||
onTap: () {
|
||||
widget.onSelect('contact', contact);
|
||||
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
...filteredRooms.map((room) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.meeting_room,
|
||||
contact: room,
|
||||
title: room.displayName,
|
||||
subtitle: room.publicKeyShort,
|
||||
emoji: room.roleEmoji,
|
||||
isSelected: _isSelected('room', room),
|
||||
onTap: () {
|
||||
widget.onSelect('room', room);
|
||||
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
],
|
||||
|
||||
// Empty state
|
||||
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
|
||||
if (widget.contacts.isEmpty &&
|
||||
widget.rooms.isEmpty &&
|
||||
widget.channels.isEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
|
||||
Widget _buildRecipientTile({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required Contact contact,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
String? emoji,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
|
||||
title: Row(
|
||||
children: [
|
||||
if (emoji != null && emoji.isNotEmpty) ...[
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
|
||||
Reference in New Issue
Block a user