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:
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<!-- Microphone for voice messages -->
|
<!-- Microphone for voice messages -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<uses-permission android:name="android.permission.POST_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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="91.987634">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="110.117264">
|
||||||
|
|
||||||
</testcase>
|
</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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import '../models/message.dart';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../widgets/messages/sar_update_sheet.dart';
|
import '../widgets/messages/sar_update_sheet.dart';
|
||||||
import '../widgets/messages/recipient_selector_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/message_destination_preferences.dart';
|
||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/voice_recorder_service.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() {
|
String _getDestinationLabel() {
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel &&
|
MessageDestinationPreferences.destinationTypeChannel &&
|
||||||
@@ -466,10 +454,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(
|
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||||
sentMessage,
|
|
||||||
contact: _selectedRecipient,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send message to selected recipient
|
// Send message to selected recipient
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
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() {
|
void _showComposerActions() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1126,9 +1121,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.add_location_alt),
|
leading: const Icon(Icons.add_location_alt),
|
||||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_showSarDialog();
|
_showSarDialog();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_voiceSupported)
|
if (_voiceSupported)
|
||||||
@@ -1138,13 +1134,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
||||||
onTap: _isSendingVoice
|
onTap: _isSendingVoice
|
||||||
? null
|
? null
|
||||||
: () {
|
: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
_stopAndSendVoice();
|
await _stopAndSendVoice();
|
||||||
} else {
|
} else {
|
||||||
_startVoiceRecording();
|
await _startVoiceRecording();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1153,9 +1150,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
title: const Text('Send image from gallery'),
|
title: const Text('Send image from gallery'),
|
||||||
onTap: _isSendingImage
|
onTap: _isSendingImage
|
||||||
? null
|
? null
|
||||||
: () {
|
: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_pickAndSendImage(source: ImageSource.gallery);
|
await _pickAndSendImage(source: ImageSource.gallery);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1164,18 +1162,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
title: const Text('Take photo'),
|
title: const Text('Take photo'),
|
||||||
onTap: _isSendingImage
|
onTap: _isSendingImage
|
||||||
? null
|
? null
|
||||||
: () {
|
: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_pickAndSendImage(source: ImageSource.camera);
|
await _pickAndSendImage(source: ImageSource.camera);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.grid_3x3),
|
leading: const Icon(Icons.grid_3x3),
|
||||||
title: const Text('Start Tic-Tac-Toe'),
|
title: const Text('Start Tic-Tac-Toe'),
|
||||||
subtitle: const Text('DM only'),
|
subtitle: const Text('DM only'),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_startTicTacToeGame();
|
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(
|
Future<void> _sendSarMessage(
|
||||||
String emoji,
|
String emoji,
|
||||||
String name,
|
String name,
|
||||||
@@ -1496,6 +1513,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return filteredMessages;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<MessagesProvider>(
|
return Consumer<MessagesProvider>(
|
||||||
@@ -1509,551 +1548,33 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
onTap: () => FocusScope.of(context).unfocus(),
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Messages list with pull-to-refresh
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: MessagesContent(
|
||||||
|
messages: messages,
|
||||||
|
scrollController: _scrollController,
|
||||||
|
highlightedMessageId: _highlightedMessageId,
|
||||||
onRefresh: _handleRefresh,
|
onRefresh: _handleRefresh,
|
||||||
child: messages.isEmpty
|
onNavigateToMap: widget.onNavigateToMap,
|
||||||
? LayoutBuilder(
|
onMessageTap: _handleMessageTap,
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
MessagesComposer(
|
||||||
// Message input area
|
textController: _textController,
|
||||||
Container(
|
focusNode: _focusNode,
|
||||||
decoration: BoxDecoration(
|
messageByteLimiter: _messageByteLimiter,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
messageByteCount: _messageByteCount,
|
||||||
),
|
maxMessageBytes: _maxMessageBytes,
|
||||||
child: Column(
|
isRecording: _isRecording,
|
||||||
mainAxisSize: MainAxisSize.min,
|
isSendingVoice: _isSendingVoice,
|
||||||
children: [
|
voiceSupported: _voiceSupported,
|
||||||
SafeArea(
|
bottomPadding: composerBottomPadding,
|
||||||
top: false,
|
destinationLabel: _getDestinationLabel(),
|
||||||
child: Padding(
|
destinationAvatar: _buildDestinationAvatar(context),
|
||||||
padding: EdgeInsets.fromLTRB(
|
onShowComposerActions: _showComposerActions,
|
||||||
10,
|
onShowRecipientSelector: _showRecipientSelector,
|
||||||
10,
|
onStartVoiceRecording: _startVoiceRecording,
|
||||||
10,
|
onStopAndSendVoice: _stopAndSendVoice,
|
||||||
composerBottomPadding,
|
onSendMessage: _sendMessage,
|
||||||
),
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class VoiceRecorderService {
|
|||||||
|
|
||||||
/// Request microphone permission. Returns true if granted.
|
/// Request microphone permission. Returns true if granted.
|
||||||
Future<bool> requestPermission() async {
|
Future<bool> requestPermission() async {
|
||||||
return _recorder.hasPermission();
|
return _recorder.hasPermission(request: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start capturing PCM audio.
|
/// Start capturing PCM audio.
|
||||||
@@ -38,9 +38,7 @@ class VoiceRecorderService {
|
|||||||
throw StateError('VoiceRecorderService: already recording');
|
throw StateError('VoiceRecorderService: already recording');
|
||||||
}
|
}
|
||||||
|
|
||||||
_controller = StreamController<Int16List>(
|
_controller = StreamController<Int16List>(onCancel: () => _stopInternal());
|
||||||
onCancel: () => _stopInternal(),
|
|
||||||
);
|
|
||||||
_isRecording = true;
|
_isRecording = true;
|
||||||
|
|
||||||
_startRecording(
|
_startRecording(
|
||||||
@@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor {
|
|||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
}) : _enableCompressor = enableCompressor,
|
}) : _enableCompressor = enableCompressor,
|
||||||
_enableLimiter = enableLimiter,
|
_enableLimiter = enableLimiter,
|
||||||
_compressor = _SimpleCompressor(
|
_compressor = _SimpleCompressor(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
thresholdDb: -18.0,
|
thresholdDb: -18.0,
|
||||||
ratio: 2.5,
|
ratio: 2.5,
|
||||||
attackMs: 8.0,
|
attackMs: 8.0,
|
||||||
releaseMs: 120.0,
|
releaseMs: 120.0,
|
||||||
makeupGainDb: 4.0,
|
makeupGainDb: 4.0,
|
||||||
),
|
),
|
||||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||||
|
|
||||||
Int16List process(Int16List input) {
|
Int16List process(Int16List input) {
|
||||||
final output = Int16List(input.length);
|
final output = Int16List(input.length);
|
||||||
@@ -214,11 +212,11 @@ class _SimpleCompressor {
|
|||||||
required double attackMs,
|
required double attackMs,
|
||||||
required double releaseMs,
|
required double releaseMs,
|
||||||
required double makeupGainDb,
|
required double makeupGainDb,
|
||||||
}) : _thresholdDb = thresholdDb,
|
}) : _thresholdDb = thresholdDb,
|
||||||
_ratio = ratio,
|
_ratio = ratio,
|
||||||
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
||||||
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
||||||
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
||||||
|
|
||||||
double process(double x) {
|
double process(double x) {
|
||||||
final absX = x.abs();
|
final absX = x.abs();
|
||||||
@@ -266,14 +264,14 @@ class _VoiceBandPassFilter {
|
|||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
required double lowCutHz,
|
required double lowCutHz,
|
||||||
required double highCutHz,
|
required double highCutHz,
|
||||||
}) : _highPass = _BiquadFilter.highPass(
|
}) : _highPass = _BiquadFilter.highPass(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
cutoffHz: lowCutHz,
|
cutoffHz: lowCutHz,
|
||||||
),
|
),
|
||||||
_lowPass = _BiquadFilter.lowPass(
|
_lowPass = _BiquadFilter.lowPass(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
cutoffHz: highCutHz,
|
cutoffHz: highCutHz,
|
||||||
);
|
);
|
||||||
|
|
||||||
Int16List process(Int16List input) {
|
Int16List process(Int16List input) {
|
||||||
final output = Int16List(input.length);
|
final output = Int16List(input.length);
|
||||||
@@ -306,11 +304,11 @@ class _BiquadFilter {
|
|||||||
required double b2,
|
required double b2,
|
||||||
required double a1,
|
required double a1,
|
||||||
required double a2,
|
required double a2,
|
||||||
}) : _b0 = b0,
|
}) : _b0 = b0,
|
||||||
_b1 = b1,
|
_b1 = b1,
|
||||||
_b2 = b2,
|
_b2 = b2,
|
||||||
_a1 = a1,
|
_a1 = a1,
|
||||||
_a2 = a2;
|
_a2 = a2;
|
||||||
|
|
||||||
factory _BiquadFilter.lowPass({
|
factory _BiquadFilter.lowPass({
|
||||||
required double sampleRate,
|
required double sampleRate,
|
||||||
|
|||||||
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:flutter/services.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../utils/location_formats.dart';
|
||||||
|
|
||||||
/// Reusable location display widget with tap-to-show modal
|
/// Reusable location display widget with tap-to-show modal
|
||||||
/// Shows coordinates in a compact format with ability to view all formats
|
/// Shows coordinates in a compact format with ability to view all formats
|
||||||
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
// Non-compact version (just text)
|
// Non-compact version (just text)
|
||||||
return Text(
|
return Text(
|
||||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontFamily: 'monospace',
|
context,
|
||||||
),
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Location Formats',
|
'Location Formats',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
_buildFormatRow(
|
_buildFormatRow(
|
||||||
context,
|
context,
|
||||||
'Plus Code',
|
'Plus Code',
|
||||||
_convertToPlusCode(location.latitude, location.longitude),
|
formatPlusCode(location.latitude, location.longitude),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
InkWell(
|
InkWell(
|
||||||
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
Clipboard.setData(ClipboardData(text: value));
|
Clipboard.setData(ClipboardData(text: value));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
|
content: Text(
|
||||||
|
AppLocalizations.of(context)!.copiedToClipboard(label),
|
||||||
|
),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
// Full MGRS would require UTM conversion library
|
// Full MGRS would require UTM conversion library
|
||||||
return '$zone$letter (approximate)';
|
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 '../../providers/app_provider.dart';
|
||||||
import 'direct_message_sheet.dart';
|
import 'direct_message_sheet.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
|
import '../../utils/location_formats.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../utils/battery_display_helper.dart';
|
import '../../utils/battery_display_helper.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -827,7 +828,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
_detailRowWithCopy(
|
_detailRowWithCopy(
|
||||||
context,
|
context,
|
||||||
'Plus Code',
|
'Plus Code',
|
||||||
_convertToPlusCode(
|
formatPlusCode(
|
||||||
contact.displayLocation!.latitude,
|
contact.displayLocation!.latitude,
|
||||||
contact.displayLocation!.longitude,
|
contact.displayLocation!.longitude,
|
||||||
),
|
),
|
||||||
@@ -1113,34 +1114,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
return '$zone$letter (approximate)';
|
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) {
|
IconData _getTypeIcon(ContactType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ContactType.chat:
|
case ContactType.chat:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '../../utils/voice_message_parser.dart';
|
|||||||
import '../../utils/image_message_parser.dart';
|
import '../../utils/image_message_parser.dart';
|
||||||
import '../../utils/tictactoe_message_parser.dart';
|
import '../../utils/tictactoe_message_parser.dart';
|
||||||
import '../../utils/avatar_label_helper.dart';
|
import '../../utils/avatar_label_helper.dart';
|
||||||
|
import '../../utils/location_formats.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
@@ -116,31 +117,16 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
Widget _buildBubbleMetaFooter(
|
Widget _buildBubbleMetaFooter(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required Message message,
|
required Message message,
|
||||||
required bool isOwnMessage,
|
|
||||||
required bool isSarMarker,
|
required bool isSarMarker,
|
||||||
}) {
|
}) {
|
||||||
final metaColor = Theme.of(
|
final metaColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||||
|
|
||||||
final items = <Widget>[
|
final items = <Widget>[];
|
||||||
Text(
|
|
||||||
message.getLocalizedTimeAgo(context),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: metaColor,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!isOwnMessage && !isSarMarker && message.pathLen < 255) {
|
if (!isSarMarker && message.pathLen < 255) {
|
||||||
items.addAll([
|
items.addAll([
|
||||||
Text(
|
|
||||||
' • ',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
|
||||||
),
|
|
||||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
@@ -149,9 +135,25 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
).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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
||||||
child: Align(
|
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(
|
Widget _buildReceivedSignalStatus(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Message message, {
|
Message message, {
|
||||||
@@ -1734,6 +1749,49 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
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) {
|
String _hopDebugLabel(Message message) {
|
||||||
if (message.pathLen >= 255 && message.isContactMessage) {
|
if (message.pathLen >= 255 && message.isContactMessage) {
|
||||||
return 'Direct (raw: ${message.pathLen})';
|
return 'Direct (raw: ${message.pathLen})';
|
||||||
@@ -2137,78 +2195,78 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header: Badge (if SAR or drawing) and time
|
// Header badge for drawing messages
|
||||||
if (isSarMarker || message.isDrawing)
|
if (message.isDrawing)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (isSarMarker)
|
Container(
|
||||||
Container(
|
padding: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(
|
horizontal: 10,
|
||||||
horizontal: 10,
|
vertical: 4,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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)
|
// Sender info row (shown for all messages)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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)
|
// Unread indicator badge (only for regular messages, not SAR/drawing)
|
||||||
if (!message.isRead &&
|
if (!message.isRead &&
|
||||||
!message.isSentMessage &&
|
!message.isSentMessage &&
|
||||||
@@ -2255,55 +2313,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (message.isChannelMessage)
|
if (message.isChannelMessage)
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Container(
|
child: _buildChannelHeaderPill(
|
||||||
padding: const EdgeInsets.symmetric(
|
context,
|
||||||
horizontal: 8,
|
label: isOwnMessage
|
||||||
vertical: 3,
|
? recipientDisplayName!
|
||||||
),
|
: channelDisplayName!,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -2407,6 +2421,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
softWrap: false,
|
||||||
),
|
),
|
||||||
if (message.sarNotes != null &&
|
if (message.sarNotes != null &&
|
||||||
message.sarNotes!.isNotEmpty) ...[
|
message.sarNotes!.isNotEmpty) ...[
|
||||||
@@ -2455,27 +2472,64 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Row(
|
||||||
Icons.place_outlined,
|
children: [
|
||||||
size: 15,
|
Icon(
|
||||||
color: _getSarMarkerBorderColor(
|
Icons.place_outlined,
|
||||||
context,
|
size: 15,
|
||||||
isDarkMode,
|
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),
|
const SizedBox(height: 6),
|
||||||
Expanded(
|
Row(
|
||||||
child: Text(
|
children: [
|
||||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
Icon(
|
||||||
style: Theme.of(context).textTheme.labelMedium
|
Icons.tag_rounded,
|
||||||
?.copyWith(
|
size: 15,
|
||||||
fontFamily: 'monospace',
|
color: _getSarMarkerBorderColor(
|
||||||
fontWeight: FontWeight.w700,
|
context,
|
||||||
letterSpacing: 0.15,
|
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 &&
|
if (!widget.isCompact &&
|
||||||
!isSarMarker &&
|
!isSarMarker &&
|
||||||
!message.isDrawing &&
|
!message.isDrawing &&
|
||||||
|
!message.isSentMessage &&
|
||||||
_showReceivedStats) ...[
|
_showReceivedStats) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_buildReceivedSignalStatus(
|
_buildReceivedSignalStatus(
|
||||||
@@ -2796,23 +2851,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child:
|
child: Text(
|
||||||
message.isChannelMessage &&
|
message.getLocalizedDeliveryStatus(context),
|
||||||
message.deliveryStatus ==
|
maxLines: 1,
|
||||||
MessageDeliveryStatus.sent
|
overflow: TextOverflow.ellipsis,
|
||||||
? _buildChannelEchoStatus(context, message)
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
: Text(
|
?.copyWith(
|
||||||
message.getLocalizedDeliveryStatus(context),
|
color: _getDeliveryStatusColor(
|
||||||
maxLines: 1,
|
message.deliveryStatus,
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
fontStyle: FontStyle.italic,
|
||||||
?.copyWith(
|
|
||||||
color: _getDeliveryStatusColor(
|
|
||||||
message.deliveryStatus,
|
|
||||||
),
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Show retry button for failed messages
|
// 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(
|
_buildBubbleMetaFooter(
|
||||||
context,
|
context,
|
||||||
message: message,
|
message: message,
|
||||||
isOwnMessage: isOwnMessage,
|
|
||||||
isSarMarker: isSarMarker,
|
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 'package:flutter/material.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../common/contact_avatar.dart';
|
||||||
|
|
||||||
/// Bottom sheet for selecting message recipient (channel, contact, or room)
|
/// Bottom sheet for selecting message recipient (channel, contact, or room)
|
||||||
class RecipientSelectorSheet extends StatefulWidget {
|
class RecipientSelectorSheet extends StatefulWidget {
|
||||||
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
...filteredChannels.map((channel) {
|
...filteredChannels.map((channel) {
|
||||||
return _buildRecipientTile(
|
return _buildRecipientTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.public,
|
contact: channel,
|
||||||
title: channel.getLocalizedDisplayName(context),
|
title: channel.getLocalizedDisplayName(context),
|
||||||
subtitle: channel.isPublicChannel
|
subtitle: channel.isPublicChannel
|
||||||
? l10n.broadcastToAllNearby
|
? l10n.broadcastToAllNearby
|
||||||
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
...filteredContacts.map((contact) {
|
...filteredContacts.map((contact) {
|
||||||
return _buildRecipientTile(
|
return _buildRecipientTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.person,
|
contact: contact,
|
||||||
title: contact.displayName,
|
title: contact.displayName,
|
||||||
subtitle: contact.publicKeyShort,
|
subtitle: contact.publicKeyShort,
|
||||||
emoji: contact.roleEmoji,
|
|
||||||
isSelected: _isSelected('contact', contact),
|
isSelected: _isSelected('contact', contact),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
widget.onSelect('contact', contact);
|
widget.onSelect('contact', contact);
|
||||||
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
...filteredRooms.map((room) {
|
...filteredRooms.map((room) {
|
||||||
return _buildRecipientTile(
|
return _buildRecipientTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.meeting_room,
|
contact: room,
|
||||||
title: room.displayName,
|
title: room.displayName,
|
||||||
subtitle: room.publicKeyShort,
|
subtitle: room.publicKeyShort,
|
||||||
emoji: room.roleEmoji,
|
|
||||||
isSelected: _isSelected('room', room),
|
isSelected: _isSelected('room', room),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
widget.onSelect('room', room);
|
widget.onSelect('room', room);
|
||||||
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
|
if (widget.contacts.isEmpty &&
|
||||||
|
widget.rooms.isEmpty &&
|
||||||
|
widget.channels.isEmpty) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
|
|
||||||
Widget _buildRecipientTile({
|
Widget _buildRecipientTile({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required IconData icon,
|
required Contact contact,
|
||||||
required String title,
|
required String title,
|
||||||
required String subtitle,
|
required String subtitle,
|
||||||
String? emoji,
|
|
||||||
required bool isSelected,
|
required bool isSelected,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
}) {
|
}) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Container(
|
leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
if (emoji != null && emoji.isNotEmpty) ...[
|
|
||||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
|
|||||||
Reference in New Issue
Block a user