Refactor messages tab UI

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

View File

@@ -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" />

View File

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

View File

@@ -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
? 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, onNavigateToMap: widget.onNavigateToMap,
onTap: onMessageTap: _handleMessageTap,
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(
textController: _textController,
// 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,
focusNode: _focusNode, focusNode: _focusNode,
minLines: 1, messageByteLimiter: _messageByteLimiter,
maxLines: 4, messageByteCount: _messageByteCount,
keyboardType: TextInputType.multiline, maxMessageBytes: _maxMessageBytes,
inputFormatters: [ isRecording: _isRecording,
_messageByteLimiter, isSendingVoice: _isSendingVoice,
], voiceSupported: _voiceSupported,
style: const TextStyle(fontSize: 15), bottomPadding: composerBottomPadding,
textAlignVertical: destinationLabel: _getDestinationLabel(),
TextAlignVertical.center, destinationAvatar: _buildDestinationAvatar(context),
decoration: InputDecoration( onShowComposerActions: _showComposerActions,
hintText: AppLocalizations.of( onShowRecipientSelector: _showRecipientSelector,
context, onStartVoiceRecording: _startVoiceRecording,
)!.typeYourMessage, onStopAndSendVoice: _stopAndSendVoice,
hintStyle: TextStyle( onSendMessage: _sendMessage,
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,
),
),
),
],
),
),
),
);
},
),
],
),
],
),
),
),
),
),
],
),
), ),
], ],
), ),

View File

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

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

View File

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

View File

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

View File

@@ -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,11 +2195,43 @@ 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(
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( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
@@ -2168,47 +2258,15 @@ class _MessageBubbleState extends State<MessageBubble> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), maxLines: 1,
], overflow: TextOverflow.ellipsis,
), softWrap: false,
)
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,
),
), ),
], ],
), ),
), ),
const SizedBox(width: 8),
], ],
),
// Sender info row (shown for all messages)
Row(
children: [
// 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,
),
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! ? recipientDisplayName!
: channelDisplayName!, : 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,7 +2472,10 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Icon( Icon(
Icons.place_outlined, Icons.place_outlined,
@@ -2469,7 +2489,9 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded( Expanded(
child: Text( child: Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelMedium style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith( ?.copyWith(
fontFamily: 'monospace', fontFamily: 'monospace',
fontWeight: FontWeight.w700, 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 && if (!widget.isCompact &&
!isSarMarker && !isSarMarker &&
!message.isDrawing && !message.isDrawing &&
!message.isSentMessage &&
_showReceivedStats) ...[ _showReceivedStats) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
_buildReceivedSignalStatus( _buildReceivedSignalStatus(
@@ -2796,12 +2851,7 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded( Expanded(
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: child: Text(
message.isChannelMessage &&
message.deliveryStatus ==
MessageDeliveryStatus.sent
? _buildChannelEchoStatus(context, message)
: Text(
message.getLocalizedDeliveryStatus(context), message.getLocalizedDeliveryStatus(context),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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( _buildBubbleMetaFooter(
context, context,
message: message, message: message,
isOwnMessage: isOwnMessage,
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
), ),
], ],

View File

@@ -0,0 +1,432 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
final int messageByteCount;
final int maxMessageBytes;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage;
const MessagesComposer({
super.key,
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
required this.messageByteCount,
required this.maxMessageBytes,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onSendMessage,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 10, 10, bottomPadding),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
_ComposerActionButton(
isRecording: isRecording,
onPressed: isRecording
? onStopAndSendVoice
: onShowComposerActions,
),
const SizedBox(width: 8),
Expanded(
child: _DestinationSelector(
destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar,
onTap: onShowRecipientSelector,
),
),
],
),
const SizedBox(height: 8),
ListenableBuilder(
listenable: Listenable.merge([
textController,
focusNode,
]),
builder: (context, _) {
final canSendText =
!isRecording &&
!isSendingVoice &&
textController.text.trim().isNotEmpty;
final semanticsLabel = isRecording
? 'Recording... release to send voice'
: (isSendingVoice
? 'Sending voice...'
: voiceSupported
? 'Send (long press to record voice)'
: 'Send');
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _MessageInput(
textController: textController,
focusNode: focusNode,
messageByteLimiter: messageByteLimiter,
),
),
const SizedBox(width: 8),
_SendButton(
canSendText: canSendText,
isRecording: isRecording,
isSendingVoice: isSendingVoice,
voiceSupported: voiceSupported,
semanticsLabel: semanticsLabel,
messageByteCount: messageByteCount,
maxMessageBytes: maxMessageBytes,
onSendMessage: onSendMessage,
onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice,
),
],
);
},
),
],
),
),
),
),
),
],
),
);
}
}
class _ComposerActionButton extends StatelessWidget {
final bool isRecording;
final VoidCallback onPressed;
const _ComposerActionButton({
required this.isRecording,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
tooltip: isRecording ? 'Stop recording' : 'More actions',
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
),
);
}
}
class _DestinationSelector extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onTap;
const _DestinationSelector({
required this.destinationLabel,
required this.destinationAvatar,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 10),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
}
class _MessageInput extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
const _MessageInput({
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
width: focusNode.hasFocus ? 1.4 : 1,
),
boxShadow: focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
controller: textController,
focusNode: focusNode,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [messageByteLimiter],
style: const TextStyle(fontSize: 15),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none,
isCollapsed: true,
),
textInputAction: TextInputAction.newline,
),
),
);
}
}
class _SendButton extends StatelessWidget {
final bool canSendText;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final String semanticsLabel;
final int messageByteCount;
final int maxMessageBytes;
final Future<void> Function() onSendMessage;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
const _SendButton({
required this.canSendText,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.semanticsLabel,
required this.messageByteCount,
required this.maxMessageBytes,
required this.onSendMessage,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: canSendText || (voiceSupported && !isSendingVoice),
label: semanticsLabel,
onTap: canSendText ? onSendMessage : null,
onLongPress: (voiceSupported && !isSendingVoice)
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText ? onSendMessage : null,
onLongPressStart: (voiceSupported && !isSendingVoice)
? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (voiceSupported && isRecording)
? (_) => onStopAndSendVoice()
: null,
onLongPressCancel: (voiceSupported && isRecording)
? onStopAndSendVoice
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 46,
decoration: BoxDecoration(
color: canSendText || isRecording
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: canSendText || isRecording
? Colors.transparent
: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: canSendText || isRecording
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.22),
blurRadius: 14,
offset: const Offset(0, 6),
),
]
: null,
),
child: isSendingVoice
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 22,
color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'$messageByteCount/$maxMessageBytes',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: messageByteCount > maxMessageBytes * 0.9
? Colors.orange.shade800
: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
final List<Message> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final Future<void> Function() onRefresh;
final VoidCallback? onNavigateToMap;
final ValueChanged<Message>? onMessageTap;
const MessagesContent({
super.key,
required this.messages,
required this.scrollController,
required this.highlightedMessageId,
required this.onRefresh,
this.onNavigateToMap,
this.onMessageTap,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.noMessagesYet,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context)!.pullDownToSync,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null
? null
: () => onMessageTap!(message),
);
},
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import '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,