Display unread counts per channel

This commit is contained in:
Janez T
2026-03-08 20:22:10 +01:00
parent bfd15065f0
commit 771cddb80d
7 changed files with 344 additions and 24 deletions

View File

@@ -62,6 +62,8 @@ class MessagesProvider with ChangeNotifier {
// Navigation state for message highlighting/scrolling // Navigation state for message highlighting/scrolling
String? _targetMessageId; String? _targetMessageId;
String? _targetDestinationType;
String? _targetRecipientPublicKeyHex;
// Helper function to compare Uint8List for equality // Helper function to compare Uint8List for equality
bool _listEquals(Uint8List a, Uint8List b) { bool _listEquals(Uint8List a, Uint8List b) {
@@ -183,11 +185,25 @@ class MessagesProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
String? get targetDestinationType => _targetDestinationType;
String? get targetRecipientPublicKeyHex => _targetRecipientPublicKeyHex;
void navigateToDestination(String type, {String? recipientPublicKeyHex}) {
_targetDestinationType = type;
_targetRecipientPublicKeyHex = recipientPublicKeyHex;
notifyListeners();
}
/// Clear message navigation state /// Clear message navigation state
void clearMessageNavigation() { void clearMessageNavigation() {
_targetMessageId = null; _targetMessageId = null;
} }
void clearDestinationNavigation() {
_targetDestinationType = null;
_targetRecipientPublicKeyHex = null;
}
/// Get count of unread messages (excluding sent messages and system messages) /// Get count of unread messages (excluding sent messages and system messages)
int get unreadCount => _messages int get unreadCount => _messages
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage) .where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)

View File

@@ -13,8 +13,13 @@ import '../widgets/contacts/add_channel_dialog.dart';
class ContactsTab extends StatefulWidget { class ContactsTab extends StatefulWidget {
final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
const ContactsTab({super.key, this.onNavigateToMap}); const ContactsTab({
super.key,
this.onNavigateToMap,
this.onNavigateToMessages,
});
@override @override
State<ContactsTab> createState() => _ContactsTabState(); State<ContactsTab> createState() => _ContactsTabState();
@@ -285,6 +290,7 @@ class _ContactsTabState extends State<ContactsTab> {
calculateDistance: _calculateDistanceInMeters, calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance, formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap, onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
messageCount: messagesProvider messageCount: messagesProvider
.getMessageCountForDestination(contact), .getMessageCountForDestination(contact),
unreadMessageCount: messagesProvider unreadMessageCount: messagesProvider
@@ -308,6 +314,7 @@ class _ContactsTabState extends State<ContactsTab> {
calculateDistance: _calculateDistanceInMeters, calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance, formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap, onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
messageCount: messagesProvider messageCount: messagesProvider
.getMessageCountForDestination(contact), .getMessageCountForDestination(contact),
unreadMessageCount: messagesProvider unreadMessageCount: messagesProvider
@@ -331,6 +338,7 @@ class _ContactsTabState extends State<ContactsTab> {
calculateDistance: _calculateDistanceInMeters, calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance, formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap, onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
messageCount: messagesProvider messageCount: messagesProvider
.getMessageCountForDestination(contact), .getMessageCountForDestination(contact),
unreadMessageCount: messagesProvider unreadMessageCount: messagesProvider
@@ -354,6 +362,7 @@ class _ContactsTabState extends State<ContactsTab> {
calculateDistance: _calculateDistanceInMeters, calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance, formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap, onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
messageCount: messagesProvider messageCount: messagesProvider
.getMessageCountForDestination(contact), .getMessageCountForDestination(contact),
unreadMessageCount: messagesProvider unreadMessageCount: messagesProvider

View File

@@ -688,6 +688,7 @@ class _HomeScreenState extends State<HomeScreen>
onNavigateToMap: _isMapEnabled onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map) ? () => _navigateToTab(_HomeTab.map)
: null, : null,
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
); );
case _HomeTab.sensors: case _HomeTab.sensors:
return const SensorsTab(); return const SensorsTab();

View File

@@ -128,11 +128,20 @@ class _MessagesTabState extends State<MessagesTab> {
void _checkForNavigationRequest() { void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId; final targetMessageId = messagesProvider.targetMessageId;
final targetDestinationType = messagesProvider.targetDestinationType;
if (targetMessageId != null) { if (targetMessageId != null) {
_scrollToMessage(targetMessageId); _scrollToMessage(targetMessageId);
messagesProvider.clearMessageNavigation(); messagesProvider.clearMessageNavigation();
} }
if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation();
}
} }
void _scrollToMessage(String messageId) { void _scrollToMessage(String messageId) {
@@ -304,6 +313,23 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return; if (!mounted) return;
} }
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
}) async {
Contact? recipient;
if (recipientPublicKeyHex != null) {
final contactsProvider = context.read<ContactsProvider>();
recipient = contactsProvider.contacts.where((contact) {
return contact.publicKeyHex == recipientPublicKeyHex;
}).firstOrNull;
}
await _onRecipientSelected(type, recipient);
if (!mounted) return;
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async { Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -351,7 +377,9 @@ class _MessagesTabState extends State<MessagesTab> {
/// Get icon for current destination type /// Get icon for current destination type
IconData _getDestinationIcon() { IconData _getDestinationIcon() {
if (_destinationType == if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return Icons.all_inbox;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) { MessageDestinationPreferences.destinationTypeChannel) {
return Icons.public; return Icons.public;
} else if (_destinationType == } else if (_destinationType ==
@@ -363,6 +391,9 @@ class _MessagesTabState extends State<MessagesTab> {
} }
String _getDestinationLabel() { String _getDestinationLabel() {
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return AppLocalizations.of(context)!.showAll;
}
if (_destinationType == if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel && MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) { _selectedRecipient != null) {
@@ -388,6 +419,12 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
if (!mounted) return;
ToastLogger.error(context, 'Select a channel, contact, or room first');
return;
}
try { try {
// Check destination type and send accordingly // Check destination type and send accordingly
if (_destinationType == if (_destinationType ==
@@ -1152,6 +1189,15 @@ class _MessagesTabState extends State<MessagesTab> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search messages'),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showFilteredMessageSearch();
});
},
),
ListTile( ListTile(
leading: const Icon(Icons.add_location_alt), leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker), title: Text(AppLocalizations.of(context)!.sendSarMarker),
@@ -1219,6 +1265,116 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
void _showFilteredMessageSearch() {
final messagesProvider = context.read<MessagesProvider>();
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) { Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient; final recipient = _selectedRecipient;
if (recipient != null) { if (recipient != null) {
@@ -1484,17 +1640,18 @@ class _MessagesTabState extends State<MessagesTab> {
List<Message> filteredMessages; List<Message> filteredMessages;
// If channel destination is selected, filter by selected channel. // If channel destination is selected, filter by selected channel.
if (_destinationType == if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
filteredMessages = allMessages;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) { MessageDestinationPreferences.destinationTypeChannel) {
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0; final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (selectedChannelIdx == 0) { filteredMessages = allMessages
// Public channel view keeps showing all messages (current app behavior). .where(
filteredMessages = allMessages; (message) =>
} else { message.isChannelMessage &&
filteredMessages = allMessages (message.channelIdx ?? 0) == selectedChannelIdx,
.where((message) => message.channelIdx == selectedChannelIdx) )
.toList(); .toList();
}
} }
// If a contact or room is selected, filter by recipient/sender prefixes. // If a contact or room is selected, filter by recipient/sender prefixes.
else if ((_destinationType == else if ((_destinationType ==
@@ -1503,6 +1660,10 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeRoom) && MessageDestinationPreferences.destinationTypeRoom) &&
_selectedRecipient != null) { _selectedRecipient != null) {
filteredMessages = allMessages.where((message) { filteredMessages = allMessages.where((message) {
if (!message.isContactMessage) {
return false;
}
// Include messages sent TO this recipient // Include messages sent TO this recipient
if (message.recipientPublicKey != null && if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 && message.recipientPublicKey!.length >= 6 &&

View File

@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
static const String _recipientPublicKeyKey = 'message_recipient_public_key'; static const String _recipientPublicKeyKey = 'message_recipient_public_key';
/// Destination types /// Destination types
static const String destinationTypeAll = 'all';
static const String destinationTypeChannel = 'channel'; static const String destinationTypeChannel = 'channel';
static const String destinationTypeContact = 'contact'; static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room'; static const String destinationTypeRoom = 'room';
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
final publicKey = prefs.getString(_recipientPublicKeyKey); final publicKey = prefs.getString(_recipientPublicKeyKey);
return { return {'type': type, 'publicKey': ?publicKey};
'type': type,
'publicKey': ?publicKey,
};
} }
/// Save the selected destination /// 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) /// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
static Future<void> setDestination( static Future<void> setDestination(
String type, { String type, {
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
/// Get display name for destination type /// Get display name for destination type
static String getDestinationTypeName(String type) { static String getDestinationTypeName(String type) {
switch (type) { switch (type) {
case destinationTypeAll:
return 'All';
case destinationTypeChannel: case destinationTypeChannel:
return 'Channel'; return 'Channel';
case destinationTypeContact: case destinationTypeContact:

View File

@@ -8,6 +8,8 @@ import '../../models/room_login_state.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/messages_provider.dart';
import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -21,6 +23,7 @@ class ContactTile extends StatelessWidget {
final double Function(double, double, double, double)? calculateDistance; final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance; final String Function(double)? formatDistance;
final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
final int messageCount; final int messageCount;
final int unreadMessageCount; final int unreadMessageCount;
@@ -31,6 +34,7 @@ class ContactTile extends StatelessWidget {
this.calculateDistance, this.calculateDistance,
this.formatDistance, this.formatDistance,
this.onNavigateToMap, this.onNavigateToMap,
this.onNavigateToMessages,
this.messageCount = 0, this.messageCount = 0,
this.unreadMessageCount = 0, this.unreadMessageCount = 0,
}); });
@@ -97,13 +101,7 @@ class ContactTile extends StatelessWidget {
], ],
) )
: null; : null;
void handleTap() { void handleTap() => _handlePrimaryTap(context, contact);
if (contact.type == ContactType.chat) {
_showSetRouteDialog(context, contact);
} else {
_showContactDetails(context, contact);
}
}
final onLongPress = isPingInProgress final onLongPress = isPingInProgress
? null ? 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<void> _openMessagesForContact(
BuildContext context,
Contact contact,
) async {
final messagesProvider = context.read<MessagesProvider>();
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) { void _showRoomLoginDialog(BuildContext context, Contact contact) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,

View File

@@ -47,7 +47,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
bool _isSelected(String type, Contact? contact) { bool _isSelected(String type, Contact? contact) {
if (widget.currentDestinationType != type) return false; if (widget.currentDestinationType != type) return false;
if (contact == null) return false; if (contact == null) return widget.currentRecipientPublicKey == null;
return contact.publicKeyHex == widget.currentRecipientPublicKey; return contact.publicKeyHex == widget.currentRecipientPublicKey;
} }
@@ -138,6 +138,18 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
children: [ 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 // Channels section
if (widget.channels.isNotEmpty) ...[ if (widget.channels.isNotEmpty) ...[
Padding( Padding(
@@ -348,4 +360,48 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
onTap: onTap, 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,
);
}
} }