mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Display unread counts per channel
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<ContactsTab> createState() => _ContactsTabState();
|
||||
@@ -285,6 +290,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
messageCount: messagesProvider
|
||||
.getMessageCountForDestination(contact),
|
||||
unreadMessageCount: messagesProvider
|
||||
@@ -308,6 +314,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
messageCount: messagesProvider
|
||||
.getMessageCountForDestination(contact),
|
||||
unreadMessageCount: messagesProvider
|
||||
@@ -331,6 +338,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
messageCount: messagesProvider
|
||||
.getMessageCountForDestination(contact),
|
||||
unreadMessageCount: messagesProvider
|
||||
@@ -354,6 +362,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
messageCount: messagesProvider
|
||||
.getMessageCountForDestination(contact),
|
||||
unreadMessageCount: messagesProvider
|
||||
|
||||
@@ -688,6 +688,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
onNavigateToMap: _isMapEnabled
|
||||
? () => _navigateToTab(_HomeTab.map)
|
||||
: null,
|
||||
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||
);
|
||||
case _HomeTab.sensors:
|
||||
return const SensorsTab();
|
||||
|
||||
@@ -128,11 +128,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
void _checkForNavigationRequest() {
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
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<MessagesTab> {
|
||||
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 {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
@@ -351,7 +377,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
/// 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<MessagesTab> {
|
||||
}
|
||||
|
||||
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<MessagesTab> {
|
||||
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<MessagesTab> {
|
||||
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<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) {
|
||||
final recipient = _selectedRecipient;
|
||||
if (recipient != null) {
|
||||
@@ -1484,17 +1640,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
List<Message> 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<MessagesTab> {
|
||||
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 &&
|
||||
|
||||
@@ -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<void> 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:
|
||||
|
||||
@@ -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<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) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
||||
@@ -47,7 +47,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
|
||||
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<RecipientSelectorSheet> {
|
||||
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<RecipientSelectorSheet> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user