mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Refactor messages tab UI
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
|
||||
<!-- Microphone for voice messages -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Notifications -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000555">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.42343">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.427601">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="91.987634">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="110.117264">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="184.536223">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="269.473315">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../widgets/messages/sar_update_sheet.dart';
|
||||
import '../widgets/messages/recipient_selector_sheet.dart';
|
||||
import '../widgets/messages/message_bubble.dart';
|
||||
import '../widgets/messages/messages_composer.dart';
|
||||
import '../widgets/messages/messages_content.dart';
|
||||
import '../widgets/common/contact_avatar.dart';
|
||||
import '../services/message_destination_preferences.dart';
|
||||
import '../services/voice_bitrate_preferences.dart';
|
||||
import '../services/voice_recorder_service.dart';
|
||||
@@ -314,20 +316,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get tooltip for destination button
|
||||
String _getDestinationTooltip() {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel &&
|
||||
_selectedRecipient != null) {
|
||||
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
|
||||
return '$channelName (tap to change)';
|
||||
} else if (_selectedRecipient != null) {
|
||||
final recipientName = _selectedRecipient!.displayName;
|
||||
return '$recipientName (tap to change)';
|
||||
}
|
||||
return 'Select recipient';
|
||||
}
|
||||
|
||||
String _getDestinationLabel() {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel &&
|
||||
@@ -466,10 +454,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(
|
||||
sentMessage,
|
||||
contact: _selectedRecipient,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||
|
||||
// Send message to selected recipient
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
@@ -1115,6 +1100,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runAfterSheetDismissal(
|
||||
BuildContext sheetContext,
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
Navigator.pop(sheetContext);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 180));
|
||||
if (!mounted) return;
|
||||
await action();
|
||||
}
|
||||
|
||||
void _showComposerActions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -1126,9 +1121,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_location_alt),
|
||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
_showSarDialog();
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_voiceSupported)
|
||||
@@ -1138,13 +1134,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
||||
onTap: _isSendingVoice
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
if (_isRecording) {
|
||||
_stopAndSendVoice();
|
||||
await _stopAndSendVoice();
|
||||
} else {
|
||||
_startVoiceRecording();
|
||||
await _startVoiceRecording();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -1153,9 +1150,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
title: const Text('Send image from gallery'),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_pickAndSendImage(source: ImageSource.gallery);
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _pickAndSendImage(source: ImageSource.gallery);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -1164,18 +1162,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
title: const Text('Take photo'),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_pickAndSendImage(source: ImageSource.camera);
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _pickAndSendImage(source: ImageSource.camera);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.grid_3x3),
|
||||
title: const Text('Start Tic-Tac-Toe'),
|
||||
subtitle: const Text('DM only'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_startTicTacToeGame();
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _startTicTacToeGame();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -1185,6 +1185,23 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDestinationAvatar(BuildContext context) {
|
||||
final recipient = _selectedRecipient;
|
||||
if (recipient != null) {
|
||||
return ContactAvatar(
|
||||
contact: recipient,
|
||||
radius: 14,
|
||||
displayName: _getDestinationLabel(),
|
||||
);
|
||||
}
|
||||
|
||||
return Icon(
|
||||
_getDestinationIcon(),
|
||||
size: 17,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendSarMessage(
|
||||
String emoji,
|
||||
String name,
|
||||
@@ -1496,6 +1513,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
return filteredMessages;
|
||||
}
|
||||
|
||||
void _handleMessageTap(Message message) {
|
||||
if (widget.onNavigateToMap == null) return;
|
||||
|
||||
if (message.isSarMarker && message.sarGpsCoordinates != null) {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap?.call();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.isDrawing && message.drawingId != null) {
|
||||
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
final drawingProvider = context.read<DrawingProvider>();
|
||||
mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MessagesProvider>(
|
||||
@@ -1509,551 +1548,33 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Column(
|
||||
children: [
|
||||
// Messages list with pull-to-refresh
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
child: MessagesContent(
|
||||
messages: messages,
|
||||
scrollController: _scrollController,
|
||||
highlightedMessageId: _highlightedMessageId,
|
||||
onRefresh: _handleRefresh,
|
||||
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];
|
||||
final isHighlighted =
|
||||
message.id == _highlightedMessageId;
|
||||
|
||||
return MessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
isHighlighted: isHighlighted,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onTap:
|
||||
widget.onNavigateToMap != null &&
|
||||
message.isSarMarker &&
|
||||
message.sarGpsCoordinates != null
|
||||
? () {
|
||||
final mapProvider = context
|
||||
.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
: widget.onNavigateToMap != null &&
|
||||
message.isDrawing &&
|
||||
message.drawingId != null
|
||||
? () {
|
||||
debugPrint(
|
||||
'🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}',
|
||||
);
|
||||
final mapProvider = context
|
||||
.read<MapProvider>();
|
||||
final drawingProvider = context
|
||||
.read<DrawingProvider>();
|
||||
mapProvider.navigateToDrawing(
|
||||
message.drawingId!,
|
||||
drawingProvider,
|
||||
);
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
onMessageTap: _handleMessageTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Message input area
|
||||
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,
|
||||
composerBottomPadding,
|
||||
),
|
||||
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: [
|
||||
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: _isRecording
|
||||
? _stopAndSendVoice
|
||||
: _showComposerActions,
|
||||
color: _isRecording
|
||||
? Colors.red
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: _showRecipientSelector,
|
||||
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: [
|
||||
Icon(
|
||||
_getDestinationIcon(),
|
||||
size: 17,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getDestinationLabel(),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: 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,
|
||||
MessagesComposer(
|
||||
textController: _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
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 Semantics(
|
||||
button: true,
|
||||
enabled:
|
||||
canSendText ||
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice),
|
||||
label: semanticsLabel,
|
||||
onTap: canSendText
|
||||
? _sendMessage
|
||||
: null,
|
||||
onLongPress:
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice)
|
||||
? () {
|
||||
if (_isRecording) {
|
||||
_stopAndSendVoice();
|
||||
return;
|
||||
}
|
||||
_startVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message: semanticsLabel,
|
||||
excludeFromSemantics: true,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: canSendText
|
||||
? () {
|
||||
debugPrint(
|
||||
'👆 [MessagesTab] Send button tapped '
|
||||
'(canSendText=$canSendText, '
|
||||
'textLength=${_textController.text.trim().length}, '
|
||||
'recording=$_isRecording, '
|
||||
'sendingVoice=$_isSendingVoice)',
|
||||
);
|
||||
_sendMessage();
|
||||
}
|
||||
: null,
|
||||
onLongPressStart:
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice)
|
||||
? (_) {
|
||||
debugPrint(
|
||||
'🎙️ [MessagesTab] Send button long-press start '
|
||||
'(voiceSupported=$_voiceSupported, '
|
||||
'sendingVoice=$_isSendingVoice, '
|
||||
'recording=$_isRecording)',
|
||||
);
|
||||
_startVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
onLongPressEnd:
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? (_) {
|
||||
debugPrint(
|
||||
'🎙️ [MessagesTab] Send button long-press end '
|
||||
'(recording=$_isRecording)',
|
||||
);
|
||||
_stopAndSendVoice();
|
||||
}
|
||||
: null,
|
||||
onLongPressCancel:
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? () {
|
||||
debugPrint(
|
||||
'🎙️ [MessagesTab] Send button long-press cancel '
|
||||
'(recording=$_isRecording)',
|
||||
);
|
||||
_stopAndSendVoice();
|
||||
}
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
messageByteLimiter: _messageByteLimiter,
|
||||
messageByteCount: _messageByteCount,
|
||||
maxMessageBytes: _maxMessageBytes,
|
||||
isRecording: _isRecording,
|
||||
isSendingVoice: _isSendingVoice,
|
||||
voiceSupported: _voiceSupported,
|
||||
bottomPadding: composerBottomPadding,
|
||||
destinationLabel: _getDestinationLabel(),
|
||||
destinationAvatar: _buildDestinationAvatar(context),
|
||||
onShowComposerActions: _showComposerActions,
|
||||
onShowRecipientSelector: _showRecipientSelector,
|
||||
onStartVoiceRecording: _startVoiceRecording,
|
||||
onStopAndSendVoice: _stopAndSendVoice,
|
||||
onSendMessage: _sendMessage,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ class VoiceRecorderService {
|
||||
|
||||
/// Request microphone permission. Returns true if granted.
|
||||
Future<bool> requestPermission() async {
|
||||
return _recorder.hasPermission();
|
||||
return _recorder.hasPermission(request: true);
|
||||
}
|
||||
|
||||
/// Start capturing PCM audio.
|
||||
@@ -38,9 +38,7 @@ class VoiceRecorderService {
|
||||
throw StateError('VoiceRecorderService: already recording');
|
||||
}
|
||||
|
||||
_controller = StreamController<Int16List>(
|
||||
onCancel: () => _stopInternal(),
|
||||
);
|
||||
_controller = StreamController<Int16List>(onCancel: () => _stopInternal());
|
||||
_isRecording = true;
|
||||
|
||||
_startRecording(
|
||||
|
||||
25
lib/utils/location_formats.dart
Normal file
25
lib/utils/location_formats.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
String formatPlusCode(double lat, double lon) {
|
||||
const base = '23456789CFGHJMPQRVWX';
|
||||
|
||||
var normalizedLat = (lat + 90) / 180;
|
||||
var normalizedLon = (lon + 180) / 360;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
if (i == 4) {
|
||||
buffer.write('+');
|
||||
}
|
||||
|
||||
final latDigit = (normalizedLat * 20).floor() % 20;
|
||||
final lonDigit = (normalizedLon * 20).floor() % 20;
|
||||
|
||||
buffer
|
||||
..write(base[latDigit])
|
||||
..write(base[lonDigit]);
|
||||
|
||||
normalizedLat = (normalizedLat * 20) % 1;
|
||||
normalizedLon = (normalizedLon * 20) % 1;
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
],
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
@@ -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,11 +2195,43 @@ 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: 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,
|
||||
@@ -2168,47 +2258,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
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,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
|
||||
// Sender info row (shown for all messages)
|
||||
Row(
|
||||
children: [
|
||||
// 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
|
||||
child: _buildChannelHeaderPill(
|
||||
context,
|
||||
label: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
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,7 +2472,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.place_outlined,
|
||||
@@ -2469,7 +2489,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w700,
|
||||
@@ -2479,6 +2501,38 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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,12 +2851,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
message.isChannelMessage &&
|
||||
message.deliveryStatus ==
|
||||
MessageDeliveryStatus.sent
|
||||
? _buildChannelEchoStatus(context, message)
|
||||
: Text(
|
||||
child: Text(
|
||||
message.getLocalizedDeliveryStatus(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -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