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,