diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b4322773..cd5c6502 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -658,6 +658,27 @@ class MeshCoreConnector extends ChangeNotifier { } } + void setContactUnreadCount(String contactKeyHex, int count) { + _contactUnreadCount[contactKeyHex] = count; + _unreadStore.saveContactUnreadCount( + Map.from(_contactUnreadCount), + ); + notifyListeners(); + } + + void setChannelUnreadCount(int channelIndex, int count) { + final channel = _findChannelByIndex(channelIndex); + if (channel != null) { + channel.unreadCount = count; + unawaited( + _channelStore.saveChannels( + _channels.isNotEmpty ? _channels : _cachedChannels, + ), + ); + notifyListeners(); + } + } + void markChannelRead(int channelIndex) { final channel = _findChannelByIndex(channelIndex); if (channel != null && channel.unreadCount > 0) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8ad6bf37..c72bad9b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -824,6 +824,8 @@ } } }, + "chat_markAsUnread": "Mark as Unread", + "chat_newMessages": "New messages", "chat_openLink": "Open Link?", "chat_openLinkConfirmation": "Do you want to open this link in your browser?", "chat_open": "Open", diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a2a88b0d..e664ec95 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1527,6 +1527,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'Unread: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Open Link?'; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 9a17a2bc..2207fd5f 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -32,6 +32,7 @@ import '../widgets/message_translation_button.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/radio_stats_entry.dart'; import '../widgets/translated_message_content.dart'; +import '../widgets/unread_divider.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; @@ -60,12 +61,14 @@ class _ChannelChatScreenState extends State { MeshCoreConnector? _connector; DateTime? _lastChannelSendAt; bool _channelSkipNextBottomSnap = false; + String? _unreadDividerMessageId; @override void initState() { super.initState(); _textFieldFocusNode.addListener(_onTextFieldFocusChange); _scrollController.onScrollNearTop = _loadOlderMessages; + _scrollController.showJumpToBottom.addListener(_clearDividerAtBottom); SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final connector = context.read(); @@ -74,12 +77,15 @@ class _ChannelChatScreenState extends State { final unread = widget.initialUnreadCount; final messages = connector.getChannelMessages(widget.channel); ChannelMessage? anchor; - if (settings.jumpToOldestUnread && unread > 0) { + if (unread > 0) { anchor = _findOldestUnreadChannelAnchor(messages, unread); } + setState(() { + if (anchor != null) _unreadDividerMessageId = anchor.messageId; + }); connector.setActiveChannel(idx); _connector = connector; - if (anchor != null) { + if (anchor != null && settings.jumpToOldestUnread) { _channelSkipNextBottomSnap = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -112,6 +118,13 @@ class _ChannelChatScreenState extends State { return oldest; } + void _clearDividerAtBottom() { + if (!_scrollController.showJumpToBottom.value && + _unreadDividerMessageId != null) { + setState(() => _unreadDividerMessageId = null); + } + } + void _onTextFieldFocusChange() { if (_textFieldFocusNode.hasFocus && mounted) { _scrollController.handleKeyboardOpen(); @@ -133,6 +146,7 @@ class _ChannelChatScreenState extends State { @override void dispose() { _connector?.setActiveChannel(null); + _scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom); _textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.dispose(); _textController.dispose(); @@ -331,6 +345,10 @@ class _ChannelChatScreenState extends State { if (!_messageKeys.containsKey(message.messageId)) { _messageKeys[message.messageId] = GlobalKey(); } + final isUnreadAnchor = + _unreadDividerMessageId != null && + message.messageId == + _unreadDividerMessageId; return Container( key: _messageKeys[message.messageId]!, child: Builder( @@ -339,10 +357,20 @@ class _ChannelChatScreenState extends State { .select( (service) => service.scale, ); - return _buildMessageBubble( + final bubble = _buildMessageBubble( message, textScale, ); + if (isUnreadAnchor) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const UnreadDivider(), + bubble, + ], + ); + } + return bubble; }, ), ); @@ -362,6 +390,19 @@ class _ChannelChatScreenState extends State { ); } + void _markAsUnread(ChannelMessage message) { + final connector = context.read(); + final messages = connector.getChannelMessages(widget.channel); + var count = 0; + var found = false; + for (final m in messages) { + if (m.messageId == message.messageId) found = true; + if (found && !m.isOutgoing) count++; + } + connector.setChannelUnreadCount(widget.channel.index, count); + Navigator.pop(context); + } + Widget _buildMessageBubble(ChannelMessage message, double textScale) { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; @@ -1288,6 +1329,15 @@ class _ChannelChatScreenState extends State { _copyMessageText(message.text); }, ), + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: Text(context.l10n.chat_markAsUnread), + onTap: () { + Navigator.pop(sheetContext); + _markAsUnread(message); + }, + ), ListTile( leading: const Icon(Icons.delete_outline), title: Text(context.l10n.common_delete), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index e1b81b61..3d29fc30 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -44,6 +44,7 @@ import '../widgets/translated_message_content.dart'; import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; import '../helpers/snack_bar_builder.dart'; +import '../widgets/unread_divider.dart'; import 'telemetry_screen.dart'; class ChatScreen extends StatefulWidget { @@ -68,6 +69,7 @@ class _ChatScreenState extends State { bool _isLoadingOlder = false; MeshCoreConnector? _connector; Message? _pendingUnreadScrollTarget; + String? _unreadDividerMessageId; DateTime? _lastTextSendAt; @override @@ -75,6 +77,7 @@ class _ChatScreenState extends State { super.initState(); _textFieldFocusNode.addListener(_onTextFieldFocusChange); _scrollController.onScrollNearTop = _loadOlderMessages; + _scrollController.showJumpToBottom.addListener(_clearDividerAtBottom); SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final connector = context.read(); @@ -83,13 +86,18 @@ class _ChatScreenState extends State { final unread = widget.initialUnreadCount; final messages = connector.getMessages(widget.contact); Message? anchor; - if (settings.jumpToOldestUnread && unread > 0) { + if (unread > 0) { anchor = _findOldestUnreadAnchor(messages, unread); } + setState(() { + if (anchor != null) _unreadDividerMessageId = anchor.messageId; + if (anchor != null && settings.jumpToOldestUnread) { + _pendingUnreadScrollTarget = anchor; + } + }); connector.setActiveContact(keyHex); _connector = connector; - if (anchor != null) { - setState(() => _pendingUnreadScrollTarget = anchor); + if (anchor != null && settings.jumpToOldestUnread) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _scrollController.jumpToEstimatedOffset( @@ -128,6 +136,13 @@ class _ChatScreenState extends State { return oldest; } + void _clearDividerAtBottom() { + if (!_scrollController.showJumpToBottom.value && + _unreadDividerMessageId != null) { + setState(() => _unreadDividerMessageId = null); + } + } + void _onTextFieldFocusChange() { if (_textFieldFocusNode.hasFocus && mounted) { _scrollController.handleKeyboardOpen(); @@ -149,6 +164,7 @@ class _ChatScreenState extends State { @override void dispose() { _connector?.setActiveContact(null); + _scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom); _textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.dispose(); _textController.dispose(); @@ -498,10 +514,18 @@ class _ChatScreenState extends State { onRetryReaction: (msg, emoji) => _sendReaction(msg, contact, emoji), ); + final isUnreadAnchor = _unreadDividerMessageId != null && + message.messageId == _unreadDividerMessageId; + final child = isUnreadAnchor + ? Column( + mainAxisSize: MainAxisSize.min, + children: [const UnreadDivider(), bubble], + ) + : bubble; if (identical(message, _pendingUnreadScrollTarget)) { - return KeyedSubtree(key: _unreadScrollKey, child: bubble); + return KeyedSubtree(key: _unreadScrollKey, child: child); } - return bubble; + return child; }, ); }, @@ -509,6 +533,19 @@ class _ChatScreenState extends State { ); } + void _markAsUnread(Message message) { + final connector = context.read(); + final messages = connector.getMessages(widget.contact); + var count = 0; + var found = false; + for (final m in messages) { + if (m.messageId == message.messageId) found = true; + if (found && !m.isOutgoing && !m.isCli) count++; + } + connector.setContactUnreadCount(widget.contact.publicKeyHex, count); + Navigator.pop(context); + } + Widget _buildInputBar(MeshCoreConnector connector) { final maxBytes = maxContactMessageBytes(); final colorScheme = Theme.of(context).colorScheme; @@ -1465,6 +1502,15 @@ class _ChatScreenState extends State { _copyMessageText(message.text); }, ), + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: Text(context.l10n.chat_markAsUnread), + onTap: () { + Navigator.pop(sheetContext); + _markAsUnread(message); + }, + ), ListTile( leading: const Icon(Icons.delete_outline), title: Text(context.l10n.common_delete), diff --git a/lib/widgets/unread_divider.dart b/lib/widgets/unread_divider.dart new file mode 100644 index 00000000..f9ebd1d0 --- /dev/null +++ b/lib/widgets/unread_divider.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import '../l10n/l10n.dart'; + +class UnreadDivider extends StatelessWidget { + const UnreadDivider({super.key}); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded(child: Divider(color: color)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.chat_newMessages, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded(child: Divider(color: color)), + ], + ), + ); + } +}