diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 96507ab..ca456ed 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -62,6 +62,8 @@ class MessagesProvider with ChangeNotifier { // Navigation state for message highlighting/scrolling String? _targetMessageId; + String? _targetDestinationType; + String? _targetRecipientPublicKeyHex; // Helper function to compare Uint8List for equality bool _listEquals(Uint8List a, Uint8List b) { @@ -183,11 +185,25 @@ class MessagesProvider with ChangeNotifier { notifyListeners(); } + String? get targetDestinationType => _targetDestinationType; + String? get targetRecipientPublicKeyHex => _targetRecipientPublicKeyHex; + + void navigateToDestination(String type, {String? recipientPublicKeyHex}) { + _targetDestinationType = type; + _targetRecipientPublicKeyHex = recipientPublicKeyHex; + notifyListeners(); + } + /// Clear message navigation state void clearMessageNavigation() { _targetMessageId = null; } + void clearDestinationNavigation() { + _targetDestinationType = null; + _targetRecipientPublicKeyHex = null; + } + /// Get count of unread messages (excluding sent messages and system messages) int get unreadCount => _messages .where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage) diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index ab098aa..1bf91cb 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -13,8 +13,13 @@ import '../widgets/contacts/add_channel_dialog.dart'; class ContactsTab extends StatefulWidget { final VoidCallback? onNavigateToMap; + final VoidCallback? onNavigateToMessages; - const ContactsTab({super.key, this.onNavigateToMap}); + const ContactsTab({ + super.key, + this.onNavigateToMap, + this.onNavigateToMessages, + }); @override State createState() => _ContactsTabState(); @@ -285,6 +290,7 @@ class _ContactsTabState extends State { calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, messageCount: messagesProvider .getMessageCountForDestination(contact), unreadMessageCount: messagesProvider @@ -308,6 +314,7 @@ class _ContactsTabState extends State { calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, messageCount: messagesProvider .getMessageCountForDestination(contact), unreadMessageCount: messagesProvider @@ -331,6 +338,7 @@ class _ContactsTabState extends State { calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, messageCount: messagesProvider .getMessageCountForDestination(contact), unreadMessageCount: messagesProvider @@ -354,6 +362,7 @@ class _ContactsTabState extends State { calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, messageCount: messagesProvider .getMessageCountForDestination(contact), unreadMessageCount: messagesProvider diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 9a2f5b0..5bf9d93 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -688,6 +688,7 @@ class _HomeScreenState extends State onNavigateToMap: _isMapEnabled ? () => _navigateToTab(_HomeTab.map) : null, + onNavigateToMessages: () => _navigateToTab(_HomeTab.messages), ); case _HomeTab.sensors: return const SensorsTab(); diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 4365ea2..8d45534 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -128,11 +128,20 @@ class _MessagesTabState extends State { void _checkForNavigationRequest() { final messagesProvider = context.read(); final targetMessageId = messagesProvider.targetMessageId; + final targetDestinationType = messagesProvider.targetDestinationType; if (targetMessageId != null) { _scrollToMessage(targetMessageId); messagesProvider.clearMessageNavigation(); } + + if (targetDestinationType != null) { + _applyPendingDestination( + type: targetDestinationType, + recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex, + ); + messagesProvider.clearDestinationNavigation(); + } } void _scrollToMessage(String messageId) { @@ -304,6 +313,23 @@ class _MessagesTabState extends State { if (!mounted) return; } + Future _applyPendingDestination({ + required String type, + String? recipientPublicKeyHex, + }) async { + Contact? recipient; + if (recipientPublicKeyHex != null) { + final contactsProvider = context.read(); + recipient = contactsProvider.contacts.where((contact) { + return contact.publicKeyHex == recipientPublicKeyHex; + }).firstOrNull; + } + + await _onRecipientSelected(type, recipient); + if (!mounted) return; + _focusNode.requestFocus(); + } + Future _replyToMessage(Message message) async { final l10n = AppLocalizations.of(context)!; final contactsProvider = context.read(); @@ -351,7 +377,9 @@ class _MessagesTabState extends State { /// Get icon for current destination type IconData _getDestinationIcon() { - if (_destinationType == + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + return Icons.all_inbox; + } else if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { return Icons.public; } else if (_destinationType == @@ -363,6 +391,9 @@ class _MessagesTabState extends State { } String _getDestinationLabel() { + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + return AppLocalizations.of(context)!.showAll; + } if (_destinationType == MessageDestinationPreferences.destinationTypeChannel && _selectedRecipient != null) { @@ -388,6 +419,12 @@ class _MessagesTabState extends State { return; } + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + if (!mounted) return; + ToastLogger.error(context, 'Select a channel, contact, or room first'); + return; + } + try { // Check destination type and send accordingly if (_destinationType == @@ -1152,6 +1189,15 @@ class _MessagesTabState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ + ListTile( + leading: const Icon(Icons.search), + title: const Text('Search messages'), + onTap: () async { + await _runAfterSheetDismissal(sheetContext, () async { + _showFilteredMessageSearch(); + }); + }, + ), ListTile( leading: const Icon(Icons.add_location_alt), title: Text(AppLocalizations.of(context)!.sendSarMarker), @@ -1219,6 +1265,116 @@ class _MessagesTabState extends State { ); } + void _showFilteredMessageSearch() { + final messagesProvider = context.read(); + final scopedMessages = _getFilteredMessages(messagesProvider); + final searchController = TextEditingController(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setModalState) { + final query = searchController.text.trim().toLowerCase(); + final matches = query.isEmpty + ? scopedMessages + : scopedMessages.where((message) { + return message.text.toLowerCase().contains(query) || + (message.senderName?.toLowerCase().contains(query) ?? + false); + }).toList(); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: SizedBox( + height: MediaQuery.of(sheetContext).size.height * 0.72, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: TextField( + controller: searchController, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search in current filter', + prefixIcon: const Icon(Icons.search), + suffixIcon: searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + setModalState(() {}); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onChanged: (_) => setModalState(() {}), + ), + ), + Expanded( + child: matches.isEmpty + ? Center( + child: Text( + query.isEmpty + ? 'No messages in this filter' + : 'No matches in this filter', + ), + ) + : ListView.separated( + itemCount: matches.length, + separatorBuilder: (_, _) => + const Divider(height: 1), + itemBuilder: (context, index) { + final message = matches[index]; + final title = + message.senderName?.trim().isNotEmpty == + true + ? message.senderName! + : message.isSentMessage + ? 'You' + : _getDestinationLabel(); + + return ListTile( + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + message.text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Navigator.pop(sheetContext); + _scrollToMessage(message.id); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ).whenComplete(searchController.dispose); + } + Widget _buildDestinationAvatar(BuildContext context) { final recipient = _selectedRecipient; if (recipient != null) { @@ -1484,17 +1640,18 @@ class _MessagesTabState extends State { List filteredMessages; // If channel destination is selected, filter by selected channel. - if (_destinationType == + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + filteredMessages = allMessages; + } else if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0; - if (selectedChannelIdx == 0) { - // Public channel view keeps showing all messages (current app behavior). - filteredMessages = allMessages; - } else { - filteredMessages = allMessages - .where((message) => message.channelIdx == selectedChannelIdx) - .toList(); - } + filteredMessages = allMessages + .where( + (message) => + message.isChannelMessage && + (message.channelIdx ?? 0) == selectedChannelIdx, + ) + .toList(); } // If a contact or room is selected, filter by recipient/sender prefixes. else if ((_destinationType == @@ -1503,6 +1660,10 @@ class _MessagesTabState extends State { MessageDestinationPreferences.destinationTypeRoom) && _selectedRecipient != null) { filteredMessages = allMessages.where((message) { + if (!message.isContactMessage) { + return false; + } + // Include messages sent TO this recipient if (message.recipientPublicKey != null && message.recipientPublicKey!.length >= 6 && diff --git a/lib/services/message_destination_preferences.dart b/lib/services/message_destination_preferences.dart index 7af37b0..f2b5f13 100644 --- a/lib/services/message_destination_preferences.dart +++ b/lib/services/message_destination_preferences.dart @@ -7,6 +7,7 @@ class MessageDestinationPreferences { static const String _recipientPublicKeyKey = 'message_recipient_public_key'; /// Destination types + static const String destinationTypeAll = 'all'; static const String destinationTypeChannel = 'channel'; static const String destinationTypeContact = 'contact'; static const String destinationTypeRoom = 'room'; @@ -24,14 +25,11 @@ class MessageDestinationPreferences { final publicKey = prefs.getString(_recipientPublicKeyKey); - return { - 'type': type, - 'publicKey': ?publicKey, - }; + return {'type': type, 'publicKey': ?publicKey}; } /// Save the selected destination - /// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom + /// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom /// [recipientPublicKey] - hex string of recipient's public key (required for contact/room) static Future setDestination( String type, { @@ -58,6 +56,8 @@ class MessageDestinationPreferences { /// Get display name for destination type static String getDestinationTypeName(String type) { switch (type) { + case destinationTypeAll: + return 'All'; case destinationTypeChannel: return 'Channel'; case destinationTypeContact: diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 330e422..c092b8d 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -8,6 +8,8 @@ import '../../models/room_login_state.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/map_provider.dart'; +import '../../providers/messages_provider.dart'; +import '../../services/message_destination_preferences.dart'; import 'contact_route_dialog.dart'; import 'room_login_sheet.dart'; import '../common/contact_avatar.dart'; @@ -21,6 +23,7 @@ class ContactTile extends StatelessWidget { final double Function(double, double, double, double)? calculateDistance; final String Function(double)? formatDistance; final VoidCallback? onNavigateToMap; + final VoidCallback? onNavigateToMessages; final int messageCount; final int unreadMessageCount; @@ -31,6 +34,7 @@ class ContactTile extends StatelessWidget { this.calculateDistance, this.formatDistance, this.onNavigateToMap, + this.onNavigateToMessages, this.messageCount = 0, this.unreadMessageCount = 0, }); @@ -97,13 +101,7 @@ class ContactTile extends StatelessWidget { ], ) : null; - void handleTap() { - if (contact.type == ContactType.chat) { - _showSetRouteDialog(context, contact); - } else { - _showContactDetails(context, contact); - } - } + void handleTap() => _handlePrimaryTap(context, contact); final onLongPress = isPingInProgress ? null @@ -305,6 +303,85 @@ class ContactTile extends StatelessWidget { ); } + void _handlePrimaryTap(BuildContext context, Contact contact) { + if (contact.isChannel) { + _showContactDetails(context, contact); + return; + } + + _showContactActionSheet(context, contact); + } + + void _showContactActionSheet(BuildContext context, Contact contact) { + final l10n = AppLocalizations.of(context)!; + final canMessage = + contact.type == ContactType.chat || contact.type == ContactType.room; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.message_outlined), + title: Text(l10n.messages), + enabled: canMessage, + onTap: !canMessage + ? null + : () async { + Navigator.pop(sheetContext); + await _openMessagesForContact(context, contact); + }, + ), + ListTile( + leading: const Icon(Icons.alt_route), + title: const Text('Set path'), + onTap: () { + Navigator.pop(sheetContext); + _showSetRouteDialog(context, contact); + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: Text( + l10n.deleteContact, + style: const TextStyle(color: Colors.red), + ), + onTap: () { + Navigator.pop(sheetContext); + _showDeleteConfirmation(context, contact); + }, + ), + ], + ), + ), + ); + } + + Future _openMessagesForContact( + BuildContext context, + Contact contact, + ) async { + final messagesProvider = context.read(); + final destinationType = contact.type == ContactType.room + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeContact; + + await MessageDestinationPreferences.setDestination( + destinationType, + recipientPublicKey: contact.publicKeyHex, + ); + messagesProvider.navigateToDestination( + destinationType, + recipientPublicKeyHex: contact.publicKeyHex, + ); + onNavigateToMessages?.call(); + } + void _showRoomLoginDialog(BuildContext context, Contact contact) { showModalBottomSheet( context: context, diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index 56198e2..15a3bbc 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -47,7 +47,7 @@ class _RecipientSelectorSheetState extends State { bool _isSelected(String type, Contact? contact) { if (widget.currentDestinationType != type) return false; - if (contact == null) return false; + if (contact == null) return widget.currentRecipientPublicKey == null; return contact.publicKeyHex == widget.currentRecipientPublicKey; } @@ -138,6 +138,18 @@ class _RecipientSelectorSheetState extends State { child: ListView( shrinkWrap: true, children: [ + _buildOptionTile( + context: context, + icon: Icons.all_inbox, + title: l10n.showAll, + subtitle: 'All messages', + isSelected: _isSelected('all', null), + onTap: () { + widget.onSelect('all', null); + Navigator.pop(context); + }, + ), + const Divider(), // Channels section if (widget.channels.isNotEmpty) ...[ Padding( @@ -348,4 +360,48 @@ class _RecipientSelectorSheetState extends State { onTap: onTap, ); } + + Widget _buildOptionTile({ + required BuildContext context, + required IconData icon, + required String title, + required String subtitle, + required bool isSelected, + required VoidCallback onTap, + }) { + return ListTile( + leading: CircleAvatar( + radius: 20, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon(icon, color: Theme.of(context).colorScheme.primary), + ), + title: Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ], + ), + subtitle: Text( + subtitle, + style: const TextStyle(fontSize: 12, fontFamily: 'monospace').copyWith( + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ), + trailing: isSelected + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: onTap, + ); + } }