Refactor messages tab UI

This commit is contained in:
Janez T
2026-03-07 08:16:28 +01:00
parent 70a6156e8a
commit f1d56e2000
11 changed files with 924 additions and 882 deletions

View File

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

View File

@@ -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:

View File

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

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

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

View File

@@ -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,