diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index cfb6410..a0586bf 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -23,6 +23,7 @@
+
diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml
index 330f28d..80df9d7 100644
--- a/ios/fastlane/report.xml
+++ b/ios/fastlane/report.xml
@@ -5,22 +5,22 @@
-
+
-
+
-
+
-
+
diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart
index e3b4056..bac5a64 100644
--- a/lib/screens/messages_tab.dart
+++ b/lib/screens/messages_tab.dart
@@ -19,7 +19,9 @@ import '../models/message.dart';
import '../models/contact.dart';
import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart';
-import '../widgets/messages/message_bubble.dart';
+import '../widgets/messages/messages_composer.dart';
+import '../widgets/messages/messages_content.dart';
+import '../widgets/common/contact_avatar.dart';
import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/voice_recorder_service.dart';
@@ -314,20 +316,6 @@ class _MessagesTabState extends State {
}
}
- /// Get tooltip for destination button
- String _getDestinationTooltip() {
- if (_destinationType ==
- MessageDestinationPreferences.destinationTypeChannel &&
- _selectedRecipient != null) {
- final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
- return '$channelName (tap to change)';
- } else if (_selectedRecipient != null) {
- final recipientName = _selectedRecipient!.displayName;
- return '$recipientName (tap to change)';
- }
- return 'Select recipient';
- }
-
String _getDestinationLabel() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
@@ -466,10 +454,7 @@ class _MessagesTabState extends State {
);
// Add to messages list with "sending" status
- messagesProvider.addSentMessage(
- sentMessage,
- contact: _selectedRecipient,
- );
+ messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send message to selected recipient
final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -1115,6 +1100,16 @@ class _MessagesTabState extends State {
);
}
+ Future _runAfterSheetDismissal(
+ BuildContext sheetContext,
+ Future Function() action,
+ ) async {
+ Navigator.pop(sheetContext);
+ await Future.delayed(const Duration(milliseconds: 180));
+ if (!mounted) return;
+ await action();
+ }
+
void _showComposerActions() {
showModalBottomSheet(
context: context,
@@ -1126,9 +1121,10 @@ class _MessagesTabState extends State {
ListTile(
leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
- onTap: () {
- Navigator.pop(sheetContext);
- _showSarDialog();
+ onTap: () async {
+ await _runAfterSheetDismissal(sheetContext, () async {
+ _showSarDialog();
+ });
},
),
if (_voiceSupported)
@@ -1138,13 +1134,14 @@ class _MessagesTabState extends State {
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
onTap: _isSendingVoice
? null
- : () {
- Navigator.pop(sheetContext);
- if (_isRecording) {
- _stopAndSendVoice();
- } else {
- _startVoiceRecording();
- }
+ : () async {
+ await _runAfterSheetDismissal(sheetContext, () async {
+ if (_isRecording) {
+ await _stopAndSendVoice();
+ } else {
+ await _startVoiceRecording();
+ }
+ });
},
),
ListTile(
@@ -1153,9 +1150,10 @@ class _MessagesTabState extends State {
title: const Text('Send image from gallery'),
onTap: _isSendingImage
? null
- : () {
- Navigator.pop(sheetContext);
- _pickAndSendImage(source: ImageSource.gallery);
+ : () async {
+ await _runAfterSheetDismissal(sheetContext, () async {
+ await _pickAndSendImage(source: ImageSource.gallery);
+ });
},
),
ListTile(
@@ -1164,18 +1162,20 @@ class _MessagesTabState extends State {
title: const Text('Take photo'),
onTap: _isSendingImage
? null
- : () {
- Navigator.pop(sheetContext);
- _pickAndSendImage(source: ImageSource.camera);
+ : () async {
+ await _runAfterSheetDismissal(sheetContext, () async {
+ await _pickAndSendImage(source: ImageSource.camera);
+ });
},
),
ListTile(
leading: const Icon(Icons.grid_3x3),
title: const Text('Start Tic-Tac-Toe'),
subtitle: const Text('DM only'),
- onTap: () {
- Navigator.pop(sheetContext);
- _startTicTacToeGame();
+ onTap: () async {
+ await _runAfterSheetDismissal(sheetContext, () async {
+ await _startTicTacToeGame();
+ });
},
),
],
@@ -1185,6 +1185,23 @@ class _MessagesTabState extends State {
);
}
+ 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 _sendSarMessage(
String emoji,
String name,
@@ -1496,6 +1513,28 @@ class _MessagesTabState extends State {
return filteredMessages;
}
+ void _handleMessageTap(Message message) {
+ if (widget.onNavigateToMap == null) return;
+
+ if (message.isSarMarker && message.sarGpsCoordinates != null) {
+ final mapProvider = context.read();
+ 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();
+ final drawingProvider = context.read();
+ mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
+ widget.onNavigateToMap?.call();
+ }
+ }
+
@override
Widget build(BuildContext context) {
return Consumer(
@@ -1509,551 +1548,33 @@ class _MessagesTabState extends State {
onTap: () => FocusScope.of(context).unfocus(),
child: Column(
children: [
- // Messages list with pull-to-refresh
Expanded(
- child: RefreshIndicator(
+ child: MessagesContent(
+ messages: messages,
+ scrollController: _scrollController,
+ highlightedMessageId: _highlightedMessageId,
onRefresh: _handleRefresh,
- child: messages.isEmpty
- ? LayoutBuilder(
- builder: (context, constraints) =>
- SingleChildScrollView(
- keyboardDismissBehavior:
- ScrollViewKeyboardDismissBehavior.onDrag,
- physics:
- const AlwaysScrollableScrollPhysics(),
- child: ConstrainedBox(
- constraints: BoxConstraints(
- minHeight: constraints.maxHeight,
- ),
- child: Center(
- child: Column(
- mainAxisAlignment:
- MainAxisAlignment.center,
- children: [
- Icon(
- Icons.message_outlined,
- size: 64,
- color: Theme.of(
- context,
- ).disabledColor,
- ),
- const SizedBox(height: 16),
- Text(
- AppLocalizations.of(
- context,
- )!.noMessagesYet,
- style: Theme.of(
- context,
- ).textTheme.titleLarge,
- ),
- const SizedBox(height: 8),
- Text(
- AppLocalizations.of(
- context,
- )!.pullDownToSync,
- style: Theme.of(
- context,
- ).textTheme.bodyMedium,
- textAlign: TextAlign.center,
- ),
- ],
- ),
- ),
- ),
- ),
- )
- : ListView.builder(
- controller: _scrollController,
- keyboardDismissBehavior:
- ScrollViewKeyboardDismissBehavior.onDrag,
- reverse: true,
- padding: const EdgeInsets.all(8),
- itemCount: messages.length,
- itemBuilder: (context, index) {
- final message = messages[index];
- final isHighlighted =
- message.id == _highlightedMessageId;
-
- return MessageBubble(
- key: ValueKey(message.id),
- message: message,
- isHighlighted: isHighlighted,
- onNavigateToMap: widget.onNavigateToMap,
- onTap:
- widget.onNavigateToMap != null &&
- message.isSarMarker &&
- message.sarGpsCoordinates != null
- ? () {
- final mapProvider = context
- .read();
- mapProvider.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();
- final drawingProvider = context
- .read();
- mapProvider.navigateToDrawing(
- message.drawingId!,
- drawingProvider,
- );
- widget.onNavigateToMap?.call();
- }
- : null,
- );
- },
- ),
+ onNavigateToMap: widget.onNavigateToMap,
+ onMessageTap: _handleMessageTap,
),
),
-
- // Message input area
- Container(
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surface,
- ),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- SafeArea(
- top: false,
- child: Padding(
- padding: EdgeInsets.fromLTRB(
- 10,
- 10,
- 10,
- composerBottomPadding,
- ),
- child: Container(
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).colorScheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(28),
- border: Border.all(
- color: Theme.of(
- context,
- ).dividerColor.withValues(alpha: 0.35),
- ),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.05),
- blurRadius: 18,
- offset: const Offset(0, 6),
- ),
- ],
- ),
- child: Padding(
- padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Row(
- children: [
- Container(
- width: 42,
- height: 42,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).colorScheme.surface,
- shape: BoxShape.circle,
- border: Border.all(
- color: Theme.of(
- context,
- ).dividerColor.withValues(alpha: 0.35),
- ),
- ),
- child: IconButton(
- icon: Icon(
- _isRecording ? Icons.stop : Icons.add,
- size: 22,
- ),
- tooltip: _isRecording
- ? 'Stop recording'
- : 'More actions',
- onPressed: _isRecording
- ? _stopAndSendVoice
- : _showComposerActions,
- color: _isRecording
- ? Colors.red
- : Theme.of(
- context,
- ).colorScheme.primary,
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: Material(
- color: Colors.transparent,
- child: InkWell(
- borderRadius: BorderRadius.circular(20),
- onTap: _showRecipientSelector,
- child: Ink(
- height: 42,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).colorScheme.surface,
- borderRadius: BorderRadius.circular(
- 20,
- ),
- border: Border.all(
- color: Theme.of(context)
- .dividerColor
- .withValues(alpha: 0.35),
- ),
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 14,
- ),
- child: Row(
- children: [
- Icon(
- _getDestinationIcon(),
- size: 17,
- color: Theme.of(context)
- .colorScheme
- .onSurfaceVariant,
- ),
- const SizedBox(width: 10),
- Expanded(
- child: Text(
- _getDestinationLabel(),
- overflow:
- TextOverflow.ellipsis,
- style: TextStyle(
- fontSize: 15,
- fontWeight:
- FontWeight.w600,
- color: Theme.of(
- context,
- ).colorScheme.onSurface,
- ),
- ),
- ),
- Icon(
- Icons.expand_more_rounded,
- size: 20,
- color: Theme.of(context)
- .colorScheme
- .onSurfaceVariant,
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 8),
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Expanded(
- child: AnimatedContainer(
- duration: const Duration(
- milliseconds: 180,
- ),
- constraints: const BoxConstraints(
- minHeight: 46,
- maxHeight: 132,
- ),
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).colorScheme.surface,
- borderRadius: BorderRadius.circular(24),
- border: Border.all(
- color: _focusNode.hasFocus
- ? Theme.of(
- context,
- ).colorScheme.primary
- : Theme.of(context).dividerColor
- .withValues(alpha: 0.35),
- width: _focusNode.hasFocus ? 1.4 : 1,
- ),
- boxShadow: _focusNode.hasFocus
- ? [
- BoxShadow(
- color: Theme.of(context)
- .colorScheme
- .primary
- .withValues(alpha: 0.10),
- blurRadius: 12,
- offset: const Offset(0, 4),
- ),
- ]
- : null,
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- child: TextField(
- controller: _textController,
- 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,
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- );
- },
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- ],
- ),
+ MessagesComposer(
+ textController: _textController,
+ focusNode: _focusNode,
+ messageByteLimiter: _messageByteLimiter,
+ messageByteCount: _messageByteCount,
+ maxMessageBytes: _maxMessageBytes,
+ isRecording: _isRecording,
+ isSendingVoice: _isSendingVoice,
+ voiceSupported: _voiceSupported,
+ bottomPadding: composerBottomPadding,
+ destinationLabel: _getDestinationLabel(),
+ destinationAvatar: _buildDestinationAvatar(context),
+ onShowComposerActions: _showComposerActions,
+ onShowRecipientSelector: _showRecipientSelector,
+ onStartVoiceRecording: _startVoiceRecording,
+ onStopAndSendVoice: _stopAndSendVoice,
+ onSendMessage: _sendMessage,
),
],
),
diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart
index f2ad576..fe378b7 100644
--- a/lib/services/voice_recorder_service.dart
+++ b/lib/services/voice_recorder_service.dart
@@ -18,7 +18,7 @@ class VoiceRecorderService {
/// Request microphone permission. Returns true if granted.
Future requestPermission() async {
- return _recorder.hasPermission();
+ return _recorder.hasPermission(request: true);
}
/// Start capturing PCM audio.
@@ -38,9 +38,7 @@ class VoiceRecorderService {
throw StateError('VoiceRecorderService: already recording');
}
- _controller = StreamController(
- onCancel: () => _stopInternal(),
- );
+ _controller = StreamController(onCancel: () => _stopInternal());
_isRecording = true;
_startRecording(
@@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor {
required int sampleRate,
required bool enableCompressor,
required bool enableLimiter,
- }) : _enableCompressor = enableCompressor,
- _enableLimiter = enableLimiter,
- _compressor = _SimpleCompressor(
- sampleRate: sampleRate.toDouble(),
- thresholdDb: -18.0,
- ratio: 2.5,
- attackMs: 8.0,
- releaseMs: 120.0,
- makeupGainDb: 4.0,
- ),
- _limiter = _PeakLimiter(ceilingDb: -1.0);
+ }) : _enableCompressor = enableCompressor,
+ _enableLimiter = enableLimiter,
+ _compressor = _SimpleCompressor(
+ sampleRate: sampleRate.toDouble(),
+ thresholdDb: -18.0,
+ ratio: 2.5,
+ attackMs: 8.0,
+ releaseMs: 120.0,
+ makeupGainDb: 4.0,
+ ),
+ _limiter = _PeakLimiter(ceilingDb: -1.0);
Int16List process(Int16List input) {
final output = Int16List(input.length);
@@ -214,11 +212,11 @@ class _SimpleCompressor {
required double attackMs,
required double releaseMs,
required double makeupGainDb,
- }) : _thresholdDb = thresholdDb,
- _ratio = ratio,
- _makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
- _attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
- _releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
+ }) : _thresholdDb = thresholdDb,
+ _ratio = ratio,
+ _makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
+ _attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
+ _releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
double process(double x) {
final absX = x.abs();
@@ -266,14 +264,14 @@ class _VoiceBandPassFilter {
required int sampleRate,
required double lowCutHz,
required double highCutHz,
- }) : _highPass = _BiquadFilter.highPass(
- sampleRate: sampleRate.toDouble(),
- cutoffHz: lowCutHz,
- ),
- _lowPass = _BiquadFilter.lowPass(
- sampleRate: sampleRate.toDouble(),
- cutoffHz: highCutHz,
- );
+ }) : _highPass = _BiquadFilter.highPass(
+ sampleRate: sampleRate.toDouble(),
+ cutoffHz: lowCutHz,
+ ),
+ _lowPass = _BiquadFilter.lowPass(
+ sampleRate: sampleRate.toDouble(),
+ cutoffHz: highCutHz,
+ );
Int16List process(Int16List input) {
final output = Int16List(input.length);
@@ -306,11 +304,11 @@ class _BiquadFilter {
required double b2,
required double a1,
required double a2,
- }) : _b0 = b0,
- _b1 = b1,
- _b2 = b2,
- _a1 = a1,
- _a2 = a2;
+ }) : _b0 = b0,
+ _b1 = b1,
+ _b2 = b2,
+ _a1 = a1,
+ _a2 = a2;
factory _BiquadFilter.lowPass({
required double sampleRate,
diff --git a/lib/utils/location_formats.dart b/lib/utils/location_formats.dart
new file mode 100644
index 0000000..8956cf4
--- /dev/null
+++ b/lib/utils/location_formats.dart
@@ -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();
+}
diff --git a/lib/widgets/common/location_display.dart b/lib/widgets/common/location_display.dart
index 0488209..95af749 100644
--- a/lib/widgets/common/location_display.dart
+++ b/lib/widgets/common/location_display.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart';
+import '../../utils/location_formats.dart';
/// Reusable location display widget with tap-to-show modal
/// Shows coordinates in a compact format with ability to view all formats
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontFamily: 'monospace',
- fontWeight: FontWeight.w600,
- ),
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.w600,
+ ),
),
const SizedBox(width: 6),
Icon(
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
// Non-compact version (just text)
return Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- fontFamily: 'monospace',
- ),
+ style: Theme.of(
+ context,
+ ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
);
}
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
children: [
const Text(
'Location Formats',
- style: TextStyle(
- fontSize: 20,
- fontWeight: FontWeight.bold,
- ),
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
_buildFormatRow(
context,
'Plus Code',
- _convertToPlusCode(location.latitude, location.longitude),
+ formatPlusCode(location.latitude, location.longitude),
),
const SizedBox(height: 8),
],
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
- color: Colors.grey,
- fontWeight: FontWeight.w500,
- ),
+ color: Colors.grey,
+ fontWeight: FontWeight.w500,
+ ),
),
const SizedBox(height: 4),
InkWell(
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
- content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
+ content: Text(
+ AppLocalizations.of(context)!.copiedToClipboard(label),
+ ),
duration: const Duration(seconds: 2),
),
);
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- fontFamily: 'monospace',
- fontWeight: FontWeight.w500,
- ),
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.w500,
+ ),
),
),
Icon(
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
// Full MGRS would require UTM conversion library
return '$zone$letter (approximate)';
}
-
- /// Convert to Google Plus Code format
- /// Simplified implementation - returns approximate code
- String _convertToPlusCode(double lat, double lon) {
- // This is a simplified version - full Plus Code requires the open_location_code package
- const base = '23456789CFGHJMPQRVWX';
-
- // Normalize coordinates
- lat = (lat + 90) / 180; // 0 to 1
- lon = (lon + 180) / 360; // 0 to 1
-
- String code = '';
- for (int i = 0; i < 8; i++) {
- if (i == 4) code += '+';
-
- int latDigit = (lat * 20).floor() % 20;
- int lonDigit = (lon * 20).floor() % 20;
-
- code += base[latDigit];
- code += base[lonDigit];
-
- lat = (lat * 20) % 1;
- lon = (lon * 20) % 1;
- }
-
- return code;
- }
}
diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart
index 6994581..15bef69 100644
--- a/lib/widgets/contacts/contact_tile.dart
+++ b/lib/widgets/contacts/contact_tile.dart
@@ -11,6 +11,7 @@ import '../../providers/map_provider.dart';
import '../../providers/app_provider.dart';
import 'direct_message_sheet.dart';
import 'room_login_sheet.dart';
+import '../../utils/location_formats.dart';
import '../../utils/toast_logger.dart';
import '../../utils/battery_display_helper.dart';
import '../../l10n/app_localizations.dart';
@@ -827,7 +828,7 @@ class ContactTile extends StatelessWidget {
_detailRowWithCopy(
context,
'Plus Code',
- _convertToPlusCode(
+ formatPlusCode(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
@@ -1113,34 +1114,6 @@ class ContactTile extends StatelessWidget {
return '$zone$letter (approximate)';
}
- /// Convert to Google Plus Code format
- /// Simplified implementation - returns approximate code
- String _convertToPlusCode(double lat, double lon) {
- // This is a simplified version - full Plus Code requires the open_location_code package
- // For now, return a placeholder that shows it's not fully implemented
- const base = '23456789CFGHJMPQRVWX';
-
- // Normalize coordinates
- lat = (lat + 90) / 180; // 0 to 1
- lon = (lon + 180) / 360; // 0 to 1
-
- String code = '';
- for (int i = 0; i < 8; i++) {
- if (i == 4) code += '+';
-
- int latDigit = (lat * 20).floor() % 20;
- int lonDigit = (lon * 20).floor() % 20;
-
- code += base[latDigit];
- code += base[lonDigit];
-
- lat = (lat * 20) % 1;
- lon = (lon * 20) % 1;
- }
-
- return code;
- }
-
IconData _getTypeIcon(ContactType type) {
switch (type) {
case ContactType.chat:
diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart
index 0a37116..dbb4128 100644
--- a/lib/widgets/messages/message_bubble.dart
+++ b/lib/widgets/messages/message_bubble.dart
@@ -24,6 +24,7 @@ import '../../utils/voice_message_parser.dart';
import '../../utils/image_message_parser.dart';
import '../../utils/tictactoe_message_parser.dart';
import '../../utils/avatar_label_helper.dart';
+import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
@@ -116,31 +117,16 @@ class _MessageBubbleState extends State {
Widget _buildBubbleMetaFooter(
BuildContext context, {
required Message message,
- required bool isOwnMessage,
required bool isSarMarker,
}) {
final metaColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
- final items = [
- Text(
- message.getLocalizedTimeAgo(context),
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: metaColor,
- fontWeight: FontWeight.w500,
- ),
- ),
- ];
+ final items = [];
- if (!isOwnMessage && !isSarMarker && message.pathLen < 255) {
+ if (!isSarMarker && message.pathLen < 255) {
items.addAll([
- Text(
- ' β’ ',
- style: Theme.of(
- context,
- ).textTheme.labelSmall?.copyWith(color: metaColor),
- ),
Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
@@ -149,9 +135,25 @@ class _MessageBubbleState extends State {
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
+ Text(
+ ' β’ ',
+ style: Theme.of(
+ context,
+ ).textTheme.labelSmall?.copyWith(color: metaColor),
+ ),
]);
}
+ items.add(
+ Text(
+ message.getLocalizedTimeAgo(context),
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: metaColor,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ );
+
return Padding(
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
child: Align(
@@ -1680,6 +1682,19 @@ class _MessageBubbleState extends State {
);
}
+ bool _shouldShowSentChannelStats(Message message) {
+ if (!message.isSentMessage || !message.isChannelMessage) {
+ return false;
+ }
+
+ final hasSignalData =
+ message.echoCount > 0 ||
+ message.lastEchoRssiDbm != null ||
+ message.lastEchoSnrRaw != null ||
+ message.expectedAckTag != null;
+ return _showReceivedStats && hasSignalData;
+ }
+
Widget _buildReceivedSignalStatus(
BuildContext context,
Message message, {
@@ -1734,6 +1749,49 @@ class _MessageBubbleState extends State {
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
+ Widget _buildChannelHeaderPill(
+ BuildContext context, {
+ required String label,
+ }) {
+ final labelColor = Theme.of(
+ context,
+ ).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+ decoration: BoxDecoration(
+ color: Theme.of(
+ context,
+ ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.campaign_outlined,
+ size: 11,
+ color: Theme.of(
+ context,
+ ).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
+ ),
+ const SizedBox(width: 5),
+ Flexible(
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: labelColor,
+ fontWeight: FontWeight.w600,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
String _hopDebugLabel(Message message) {
if (message.pathLen >= 255 && message.isContactMessage) {
return 'Direct (raw: ${message.pathLen})';
@@ -2137,78 +2195,78 @@ class _MessageBubbleState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // Header: Badge (if SAR or drawing) and time
- if (isSarMarker || message.isDrawing)
+ // Header badge for drawing messages
+ if (message.isDrawing)
Row(
children: [
- if (isSarMarker)
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 10,
- vertical: 4,
- ),
- decoration: BoxDecoration(
- color: _getSarMarkerBorderColor(context, isDarkMode),
- borderRadius: BorderRadius.circular(6),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Icon(
- Icons.warning_amber_rounded,
- size: 16,
- color: Colors.white,
- ),
- const SizedBox(width: 4),
- Text(
- AppLocalizations.of(context)!.sarAlert,
- style: Theme.of(context).textTheme.labelSmall
- ?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- letterSpacing: 0.5,
- ),
- ),
- ],
- ),
- )
- else if (message.isDrawing)
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 10,
- vertical: 4,
- ),
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.primary,
- borderRadius: BorderRadius.circular(6),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Icon(
- Icons.draw,
- size: 16,
- color: Colors.white,
- ),
- const SizedBox(width: 4),
- Text(
- AppLocalizations.of(context)!.mapDrawing,
- style: Theme.of(context).textTheme.labelSmall
- ?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- letterSpacing: 0.5,
- ),
- ),
- ],
- ),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 4,
),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primary,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.draw, size: 16, color: Colors.white),
+ const SizedBox(width: 4),
+ Text(
+ AppLocalizations.of(context)!.mapDrawing,
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 0.5,
+ ),
+ ),
+ ],
+ ),
+ ),
],
),
// Sender info row (shown for all messages)
Row(
children: [
+ if (isSarMarker) ...[
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: _getSarMarkerBorderColor(context, isDarkMode),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(
+ Icons.warning_amber_rounded,
+ size: 16,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ AppLocalizations.of(context)!.sarAlert,
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 0.5,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ softWrap: false,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ ],
// Unread indicator badge (only for regular messages, not SAR/drawing)
if (!message.isRead &&
!message.isSentMessage &&
@@ -2255,55 +2313,11 @@ class _MessageBubbleState extends State {
const SizedBox(width: 8),
if (message.isChannelMessage)
Flexible(
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8,
- vertical: 3,
- ),
- decoration: BoxDecoration(
- color: Theme.of(context)
- .colorScheme
- .surfaceContainerHighest
- .withValues(alpha: 0.65),
- borderRadius: BorderRadius.circular(999),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- isOwnMessage
- ? Icons.campaign_outlined
- : Icons.tag,
- size: 11,
- color: Theme.of(context)
- .textTheme
- .labelSmall
- ?.color
- ?.withValues(alpha: 0.7),
- ),
- const SizedBox(width: 5),
- Flexible(
- child: Text(
- isOwnMessage
- ? recipientDisplayName!
- : channelDisplayName!,
- style: Theme.of(context)
- .textTheme
- .labelSmall
- ?.copyWith(
- color: Theme.of(context)
- .textTheme
- .labelSmall
- ?.color
- ?.withValues(alpha: 0.82),
- fontWeight: FontWeight.w600,
- ),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- ),
- ],
- ),
+ child: _buildChannelHeaderPill(
+ context,
+ label: isOwnMessage
+ ? recipientDisplayName!
+ : channelDisplayName!,
),
)
else
@@ -2407,6 +2421,9 @@ class _MessageBubbleState extends State {
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ softWrap: false,
),
if (message.sarNotes != null &&
message.sarNotes!.isNotEmpty) ...[
@@ -2455,27 +2472,64 @@ class _MessageBubbleState extends State {
),
borderRadius: BorderRadius.circular(10),
),
- child: Row(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Icon(
- Icons.place_outlined,
- size: 15,
- color: _getSarMarkerBorderColor(
- context,
- isDarkMode,
- ),
+ Row(
+ children: [
+ Icon(
+ Icons.place_outlined,
+ size: 15,
+ color: _getSarMarkerBorderColor(
+ context,
+ isDarkMode,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
+ style: Theme.of(context)
+ .textTheme
+ .labelMedium
+ ?.copyWith(
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.w700,
+ letterSpacing: 0.15,
+ ),
+ ),
+ ),
+ ],
),
- const SizedBox(width: 6),
- Expanded(
- child: Text(
- '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
- style: Theme.of(context).textTheme.labelMedium
- ?.copyWith(
- fontFamily: 'monospace',
- fontWeight: FontWeight.w700,
- letterSpacing: 0.15,
+ const SizedBox(height: 6),
+ Row(
+ children: [
+ Icon(
+ Icons.tag_rounded,
+ size: 15,
+ color: _getSarMarkerBorderColor(
+ context,
+ isDarkMode,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ formatPlusCode(
+ message.sarGpsCoordinates!.latitude,
+ message.sarGpsCoordinates!.longitude,
),
- ),
+ style: Theme.of(context)
+ .textTheme
+ .labelMedium
+ ?.copyWith(
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.w700,
+ letterSpacing: 0.15,
+ ),
+ ),
+ ),
+ ],
),
],
),
@@ -2593,6 +2647,7 @@ class _MessageBubbleState extends State {
if (!widget.isCompact &&
!isSarMarker &&
!message.isDrawing &&
+ !message.isSentMessage &&
_showReceivedStats) ...[
const SizedBox(height: 6),
_buildReceivedSignalStatus(
@@ -2796,23 +2851,18 @@ class _MessageBubbleState extends State {
Expanded(
child: Align(
alignment: Alignment.centerLeft,
- child:
- message.isChannelMessage &&
- message.deliveryStatus ==
- MessageDeliveryStatus.sent
- ? _buildChannelEchoStatus(context, message)
- : Text(
- message.getLocalizedDeliveryStatus(context),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: Theme.of(context).textTheme.labelSmall
- ?.copyWith(
- color: _getDeliveryStatusColor(
- message.deliveryStatus,
- ),
- fontStyle: FontStyle.italic,
- ),
+ child: Text(
+ message.getLocalizedDeliveryStatus(context),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: _getDeliveryStatusColor(
+ message.deliveryStatus,
+ ),
+ fontStyle: FontStyle.italic,
),
+ ),
),
),
// Show retry button for failed messages
@@ -2858,6 +2908,10 @@ class _MessageBubbleState extends State {
],
],
),
+ if (_shouldShowSentChannelStats(message)) ...[
+ const SizedBox(height: 6),
+ _buildChannelEchoStatus(context, message),
+ ],
],
],
),
@@ -2874,7 +2928,6 @@ class _MessageBubbleState extends State {
_buildBubbleMetaFooter(
context,
message: message,
- isOwnMessage: isOwnMessage,
isSarMarker: isSarMarker,
),
],
diff --git a/lib/widgets/messages/messages_composer.dart b/lib/widgets/messages/messages_composer.dart
new file mode 100644
index 0000000..4f43815
--- /dev/null
+++ b/lib/widgets/messages/messages_composer.dart
@@ -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 Function() onStartVoiceRecording;
+ final Future Function() onStopAndSendVoice;
+ final Future 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 Function() onSendMessage;
+ final Future Function() onStartVoiceRecording;
+ final Future 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),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/messages/messages_content.dart b/lib/widgets/messages/messages_content.dart
new file mode 100644
index 0000000..73573bd
--- /dev/null
+++ b/lib/widgets/messages/messages_content.dart
@@ -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 messages;
+ final ScrollController scrollController;
+ final String? highlightedMessageId;
+ final Future Function() onRefresh;
+ final VoidCallback? onNavigateToMap;
+ final ValueChanged? 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),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart
index 36232fe..56198e2 100644
--- a/lib/widgets/messages/recipient_selector_sheet.dart
+++ b/lib/widgets/messages/recipient_selector_sheet.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../l10n/app_localizations.dart';
+import '../common/contact_avatar.dart';
/// Bottom sheet for selecting message recipient (channel, contact, or room)
class RecipientSelectorSheet extends StatefulWidget {
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State {
...filteredChannels.map((channel) {
return _buildRecipientTile(
context: context,
- icon: Icons.public,
+ contact: channel,
title: channel.getLocalizedDisplayName(context),
subtitle: channel.isPublicChannel
? l10n.broadcastToAllNearby
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State {
...filteredContacts.map((contact) {
return _buildRecipientTile(
context: context,
- icon: Icons.person,
+ contact: contact,
title: contact.displayName,
subtitle: contact.publicKeyShort,
- emoji: contact.roleEmoji,
isSelected: _isSelected('contact', contact),
onTap: () {
widget.onSelect('contact', contact);
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State {
...filteredRooms.map((room) {
return _buildRecipientTile(
context: context,
- icon: Icons.meeting_room,
+ contact: room,
title: room.displayName,
subtitle: room.publicKeyShort,
- emoji: room.roleEmoji,
isSelected: _isSelected('room', room),
onTap: () {
widget.onSelect('room', room);
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State {
],
// Empty state
- if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
+ if (widget.contacts.isEmpty &&
+ widget.rooms.isEmpty &&
+ widget.channels.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State {
Widget _buildRecipientTile({
required BuildContext context,
- required IconData icon,
+ required Contact contact,
required String title,
required String subtitle,
- String? emoji,
required bool isSelected,
required VoidCallback onTap,
}) {
return ListTile(
- leading: Container(
- width: 40,
- height: 40,
- decoration: BoxDecoration(
- color: isSelected
- ? Theme.of(context).colorScheme.primaryContainer
- : Theme.of(context).colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(20),
- ),
- child: Icon(
- icon,
- color: isSelected
- ? Theme.of(context).colorScheme.primary
- : Theme.of(context).colorScheme.onSurfaceVariant,
- ),
- ),
+ leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
title: Row(
children: [
- if (emoji != null && emoji.isNotEmpty) ...[
- Text(emoji, style: const TextStyle(fontSize: 16)),
- const SizedBox(width: 8),
- ],
Expanded(
child: Text(
title,