From 3ea2e4763e1982b72e653b19b7c0a82fc1582f7b Mon Sep 17 00:00:00 2001 From: Dmitry Polshakov Date: Sat, 25 Apr 2026 09:00:22 +0300 Subject: [PATCH 1/6] fix(chat): fix jump-to-oldest-unread scroll not reaching target message - Pass initialUnreadCount to chat screens before markRead clears it - Use two-phase scroll: jumpTo estimated offset to build lazy items, then ensureVisible for precise positioning - Await ensureVisible before clearing scroll guard to prevent scrollToBottomIfAtBottom from overriding the animation Co-Authored-By: Claude Opus 4.6 --- lib/helpers/chat_scroll_controller.dart | 19 +++++++++ lib/screens/channel_chat_screen.dart | 24 +++++++---- lib/screens/channels_screen.dart | 7 +++- lib/screens/chat_screen.dart | 55 +++++++++++++++++-------- lib/screens/contacts_screen.dart | 22 ++++++++-- lib/screens/map_screen.dart | 13 ++++-- 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/lib/helpers/chat_scroll_controller.dart b/lib/helpers/chat_scroll_controller.dart index d2c73fbf..c0d19747 100644 --- a/lib/helpers/chat_scroll_controller.dart +++ b/lib/helpers/chat_scroll_controller.dart @@ -49,6 +49,25 @@ class ChatScrollController extends ScrollController { } } + /// Jumps toward an off-screen message so that lazy ListView.builder builds + /// items near it. Only visible + cacheExtent items have real heights, so we + /// use proportion of maxScrollExtent (itself an estimate from built items' + /// avg height). Call [onJumped] on the next frame to ensureVisible/scroll + /// to the exact target. + void jumpToEstimatedOffset({ + required int unreadCount, + required int totalMessages, + required VoidCallback onJumped, + }) { + if (!hasClients || totalMessages == 0) return; + final maxExtent = position.maxScrollExtent; + final jumpOffset = maxExtent * (unreadCount / totalMessages); + if (jumpOffset > 100) { + jumpTo(jumpOffset); + } + WidgetsBinding.instance.addPostFrameCallback((_) => onJumped()); + } + void scrollToBottomIfAtBottom() { // Only scroll if jump button is NOT showing (i.e., already at bottom) if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) { diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index e5b5f67e..9a17a2bc 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -37,8 +37,13 @@ import 'map_screen.dart'; class ChannelChatScreen extends StatefulWidget { final Channel channel; + final int initialUnreadCount; - const ChannelChatScreen({super.key, required this.channel}); + const ChannelChatScreen({ + super.key, + required this.channel, + this.initialUnreadCount = 0, + }); @override State createState() => _ChannelChatScreenState(); @@ -66,13 +71,11 @@ class _ChannelChatScreenState extends State { final connector = context.read(); final settings = context.read().settings; final idx = widget.channel.index; - final unread = connector.getUnreadCountForChannelIndex(idx); + final unread = widget.initialUnreadCount; + final messages = connector.getChannelMessages(widget.channel); ChannelMessage? anchor; if (settings.jumpToOldestUnread && unread > 0) { - anchor = _findOldestUnreadChannelAnchor( - connector.getChannelMessages(widget.channel), - unread, - ); + anchor = _findOldestUnreadChannelAnchor(messages, unread); } connector.setActiveChannel(idx); _connector = connector; @@ -80,7 +83,14 @@ class _ChannelChatScreenState extends State { _channelSkipNextBottomSnap = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _scrollToMessage(anchor!.messageId); + _scrollController.jumpToEstimatedOffset( + unreadCount: unread, + totalMessages: messages.length, + onJumped: () { + if (!mounted) return; + _scrollToMessage(anchor!.messageId); + }, + ); }); } }); diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 44c7a69c..9faccf8d 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -492,13 +492,18 @@ class _ChannelsScreenState extends State ], ), onTap: () async { + final unread = + connector.getUnreadCountForChannelIndex(channel.index); connector.markChannelRead(channel.index); await Future.delayed(const Duration(milliseconds: 50)); if (context.mounted) { Navigator.push( context, MaterialPageRoute( - builder: (context) => ChannelChatScreen(channel: channel), + builder: (context) => ChannelChatScreen( + channel: channel, + initialUnreadCount: unread, + ), ), ); } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2aee61c3..e1b81b61 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -48,8 +48,13 @@ import 'telemetry_screen.dart'; class ChatScreen extends StatefulWidget { final Contact contact; + final int initialUnreadCount; - const ChatScreen({super.key, required this.contact}); + const ChatScreen({ + super.key, + required this.contact, + this.initialUnreadCount = 0, + }); @override State createState() => _ChatScreenState(); @@ -75,13 +80,11 @@ class _ChatScreenState extends State { final connector = context.read(); final settings = context.read().settings; final keyHex = widget.contact.publicKeyHex; - final unread = connector.getUnreadCountForContactKey(keyHex); + final unread = widget.initialUnreadCount; + final messages = connector.getMessages(widget.contact); Message? anchor; if (settings.jumpToOldestUnread && unread > 0) { - anchor = _findOldestUnreadAnchor( - connector.getMessages(widget.contact), - unread, - ); + anchor = _findOldestUnreadAnchor(messages, unread); } connector.setActiveContact(keyHex); _connector = connector; @@ -89,15 +92,24 @@ class _ChatScreenState extends State { setState(() => _pendingUnreadScrollTarget = anchor); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final ctx = _unreadScrollKey.currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - ctx, - duration: const Duration(milliseconds: 350), - alignment: 0.15, - ); - } - setState(() => _pendingUnreadScrollTarget = null); + _scrollController.jumpToEstimatedOffset( + unreadCount: unread, + totalMessages: messages.length, + onJumped: () async { + if (!mounted) return; + final ctx = _unreadScrollKey.currentContext; + if (ctx != null) { + await Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 350), + alignment: 0.15, + ); + } + if (mounted) { + setState(() => _pendingUnreadScrollTarget = null); + } + }, + ); }); } }); @@ -1305,11 +1317,18 @@ class _ChatScreenState extends State { } void _openChat(BuildContext context, Contact contact) { - // Check if this is a repeater - context.read().markContactRead(contact.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(contact.publicKeyHex); + connector.markContactRead(contact.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + MaterialPageRoute( + builder: (context) => ChatScreen( + contact: contact, + initialUnreadCount: unread, + ), + ), ); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 54d32990..601d44ea 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -930,10 +930,18 @@ class _ContactsScreenState extends State } else if (contact.type == advTypeRoom) { _showRoomLogin(context, contact, RoomLoginDestination.chat); } else { - context.read().markContactRead(contact.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(contact.publicKeyHex); + connector.markContactRead(contact.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + MaterialPageRoute( + builder: (context) => ChatScreen( + contact: contact, + initialUnreadCount: unread, + ), + ), ); } } @@ -988,7 +996,10 @@ class _ContactsScreenState extends State builder: (context) => RoomLoginDialog( room: room, onLogin: (password, isAdmin) { - context.read().markContactRead(room.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(room.publicKeyHex); + connector.markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( @@ -999,7 +1010,10 @@ class _ContactsScreenState extends State password: password, isAdmin: isAdmin, ) - : ChatScreen(contact: room), + : ChatScreen( + contact: room, + initialUnreadCount: unread, + ), ), ); }, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 6a8acda7..36a9fd64 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1391,11 +1391,18 @@ class _MapScreenState extends State { room: room, // onLogin(password, isAdmin) isAdmin not used for room caht screen onLogin: (password, _) { - // Navigate to chat screen after successful login - context.read().markContactRead(room.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(room.publicKeyHex); + connector.markContactRead(room.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: room)), + MaterialPageRoute( + builder: (context) => ChatScreen( + contact: room, + initialUnreadCount: unread, + ), + ), ); }, ), From 00e4f52d75217d158703b4af4a3c95dd822b986f Mon Sep 17 00:00:00 2001 From: Dmitry Polshakov Date: Sat, 25 Apr 2026 09:05:28 +0300 Subject: [PATCH 2/6] feat(chat): add "Mark as Unread" action and unread messages divider - Add "Mark as Unread" option to message context menu in both contact and channel chats - Show "New messages" divider line between read and unread messages - Add setContactUnreadCount/setChannelUnreadCount methods to connector Co-Authored-By: Claude Opus 4.6 --- lib/connector/meshcore_connector.dart | 21 ++++++++++ lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations_en.dart | 6 +++ lib/screens/channel_chat_screen.dart | 56 +++++++++++++++++++++++++-- lib/screens/chat_screen.dart | 56 ++++++++++++++++++++++++--- lib/widgets/unread_divider.dart | 32 +++++++++++++++ 6 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 lib/widgets/unread_divider.dart 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)), + ], + ), + ); + } +} From f10aeaeba8d0ba99194b72198578469f3dfeb754 Mon Sep 17 00:00:00 2001 From: Dmitry Polshakov Date: Sat, 25 Apr 2026 09:05:36 +0300 Subject: [PATCH 3/6] chore(l10n): regenerate localizations for mark-as-unread strings Co-Authored-By: Claude Opus 4.6 --- lib/l10n/app_localizations.dart | 12 +++++ lib/l10n/app_localizations_bg.dart | 6 +++ lib/l10n/app_localizations_de.dart | 6 +++ lib/l10n/app_localizations_es.dart | 6 +++ lib/l10n/app_localizations_fr.dart | 6 +++ lib/l10n/app_localizations_hu.dart | 6 +++ lib/l10n/app_localizations_it.dart | 6 +++ lib/l10n/app_localizations_ja.dart | 6 +++ lib/l10n/app_localizations_ko.dart | 6 +++ lib/l10n/app_localizations_nl.dart | 6 +++ lib/l10n/app_localizations_pl.dart | 6 +++ lib/l10n/app_localizations_pt.dart | 6 +++ lib/l10n/app_localizations_ru.dart | 6 +++ lib/l10n/app_localizations_sk.dart | 6 +++ lib/l10n/app_localizations_sl.dart | 6 +++ lib/l10n/app_localizations_sv.dart | 6 +++ lib/l10n/app_localizations_uk.dart | 6 +++ lib/l10n/app_localizations_zh.dart | 6 +++ untranslated.json | 87 +++++++++++++++++++++++++++++- 19 files changed, 200 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2c1342d0..d09bff23 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2824,6 +2824,18 @@ abstract class AppLocalizations { /// **'Unread: {count}'** String chat_unread(int count); + /// No description provided for @chat_markAsUnread. + /// + /// In en, this message translates to: + /// **'Mark as Unread'** + String get chat_markAsUnread; + + /// No description provided for @chat_newMessages. + /// + /// In en, this message translates to: + /// **'New messages'** + String get chat_newMessages; + /// No description provided for @chat_openLink. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index b3e12799..663dc0d8 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1560,6 +1560,12 @@ class AppLocalizationsBg extends AppLocalizations { return 'Непрочетени: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Отваряне на връзката?'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d7c16914..417aaa9c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1557,6 +1557,12 @@ class AppLocalizationsDe extends AppLocalizations { return 'Ungelesen: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Link öffnen?'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a1270124..a5f1a7ff 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1556,6 +1556,12 @@ class AppLocalizationsEs extends AppLocalizations { return 'Sin leer: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => '¿Abrir enlace?'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index a0063914..0a19b5bb 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1565,6 +1565,12 @@ class AppLocalizationsFr extends AppLocalizations { return 'Non lu : $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Ouvrir le lien ?'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 1ad8558c..eea1f3f5 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1567,6 +1567,12 @@ class AppLocalizationsHu extends AppLocalizations { return 'Olvasatlan: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Nyisd meg a linket?'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 3a55559a..27fcab17 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1558,6 +1558,12 @@ class AppLocalizationsIt extends AppLocalizations { return 'Non letti: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Aprire il link?'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index afb8c29b..f3239a2c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1489,6 +1489,12 @@ class AppLocalizationsJa extends AppLocalizations { return '未読: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'リンクを開く?'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index ff4bd261..57b1336f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1485,6 +1485,12 @@ class AppLocalizationsKo extends AppLocalizations { return '읽지 않음: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => '링크를 열기?'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index dd770e15..9ec1e530 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1545,6 +1545,12 @@ class AppLocalizationsNl extends AppLocalizations { return 'Nieuw: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Link openen?'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 357dd7e3..f0543bce 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1569,6 +1569,12 @@ class AppLocalizationsPl extends AppLocalizations { return 'Nieprzeczytane: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Otworzyć link?'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 2dfcd8bd..88e0cf09 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1556,6 +1556,12 @@ class AppLocalizationsPt extends AppLocalizations { return 'Não lido: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Abrir link?'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4fac42ce..b9c0322c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1560,6 +1560,12 @@ class AppLocalizationsRu extends AppLocalizations { return 'Непрочитанных: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Открыть ссылку?'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index c42e0249..0f570566 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1546,6 +1546,12 @@ class AppLocalizationsSk extends AppLocalizations { return 'Nezriadené: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Otvoriť odkaz?'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 2d89aa41..fd8a6d1c 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1541,6 +1541,12 @@ class AppLocalizationsSl extends AppLocalizations { return 'Nerešeno: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Odpreti povezavo?'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 38e08939..70f2ec15 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1535,6 +1535,12 @@ class AppLocalizationsSv extends AppLocalizations { return 'Olästa: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Öppna länk?'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8ed4b9ff..75efaf9b 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1554,6 +1554,12 @@ class AppLocalizationsUk extends AppLocalizations { return 'Непрочитано: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Відкрити посилання?'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4f38c64a..2a485ca3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1457,6 +1457,12 @@ class AppLocalizationsZh extends AppLocalizations { return '未读:$count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => '打开链接?'; diff --git a/untranslated.json b/untranslated.json index 9e26dfee..ec563845 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,86 @@ -{} \ No newline at end of file +{ + "bg": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "de": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "es": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "fr": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "hu": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "it": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "ja": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "ko": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "nl": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "pl": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "pt": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "ru": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "sk": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "sl": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "sv": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "uk": [ + "chat_markAsUnread", + "chat_newMessages" + ], + + "zh": [ + "chat_markAsUnread", + "chat_newMessages" + ] +} From 0e5f1a45c45c27915ae8c811f8d5a52e8b991bd9 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Mon, 27 Apr 2026 13:07:16 -0700 Subject: [PATCH 4/6] fix(chat): address mark-as-unread double-pop and missed map entry point - Remove stray Navigator.pop(context) in _markAsUnread for both contact and channel chats so the action no longer exits the conversation - Thread initialUnreadCount through map discovered-contact "Open Chat" button so the unread divider/jump still fires from that entry point Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/screens/channel_chat_screen.dart | 1 - lib/screens/chat_screen.dart | 1 - lib/screens/map_screen.dart | 7 ++++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 2207fd5f..9393a27a 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -400,7 +400,6 @@ class _ChannelChatScreenState extends State { if (found && !m.isOutgoing) count++; } connector.setChannelUnreadCount(widget.channel.index, count); - Navigator.pop(context); } Widget _buildMessageBubble(ChannelMessage message, double textScale) { diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 3d29fc30..62268f03 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -543,7 +543,6 @@ class _ChatScreenState extends State { if (found && !m.isOutgoing && !m.isCli) count++; } connector.setContactUnreadCount(widget.contact.publicKeyHex, count); - Navigator.pop(context); } Widget _buildInputBar(MeshCoreConnector connector) { diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 36a9fd64..39d42a49 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1463,11 +1463,16 @@ class _MapScreenState extends State { if (!contact.isActive) { connector.importDiscoveredContact(contact); } + final unread = + connector.getUnreadCountForContactKey(contact.publicKeyHex); Navigator.pop(dialogContext); Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(contact: contact), + builder: (context) => ChatScreen( + contact: contact, + initialUnreadCount: unread, + ), ), ); }, From f07993b36723cef316242449e574642120eade3b Mon Sep 17 00:00:00 2001 From: zjs81 Date: Mon, 27 Apr 2026 13:07:21 -0700 Subject: [PATCH 5/6] fix(chat): remove unnecessary Navigator.pop calls after setting unread counts --- lib/theme/mesh_theme.dart | 447 ++++++++++++++++++++++++ linux/flutter/generated_plugins.cmake | 1 - windows/flutter/generated_plugins.cmake | 1 - 3 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 lib/theme/mesh_theme.dart diff --git a/lib/theme/mesh_theme.dart b/lib/theme/mesh_theme.dart new file mode 100644 index 00000000..7c35d57c --- /dev/null +++ b/lib/theme/mesh_theme.dart @@ -0,0 +1,447 @@ +import 'package:flutter/material.dart'; + +/// MeshCore redesign palette — warm field-journal dark theme with +/// phosphor-green signal accents. Mirrors values from the redesign spec. +class MeshPalette { + MeshPalette._(); + + // Surfaces (warm near-black, olive undertone) + static const bg = Color(0xFF0F1412); + static const bg1 = Color(0xFF161C19); + static const bg2 = Color(0xFF1D2521); + static const bg3 = Color(0xFF28322D); + static const bg4 = Color(0xFF34403A); + + // Lines + static const line = Color(0xFF232C28); + static const line2 = Color(0xFF34403A); + static const line3 = Color(0xFF48564F); + + // Ink + static const ink = Color(0xFFEFF3E8); + static const ink2 = Color(0xFFBAC4B5); + static const ink3 = Color(0xFF7C8B82); + static const ink4 = Color(0xFF55635B); + + // Signal (phosphor) + static const signal = Color(0xFF7BEFA8); + static const signalDim = Color(0xFF4DC580); + static const signalBg = Color(0x177BEFA8); // ~9% alpha + static const signalLine = Color(0x427BEFA8); // ~26% + static const signalGlow = Color(0x597BEFA8); // ~35% + + // Warn (ember) + static const warn = Color(0xFFFFA552); + static const warnDim = Color(0xFFC27E3C); + static const warnBg = Color(0x1CFFA552); + static const warnLine = Color(0x4DFFA552); + + // Alert (coral) + static const alert = Color(0xFFFF6A5C); + static const alertBg = Color(0x1CFF6A5C); + static const alertLine = Color(0x52FF6A5C); + + // Blue (dusk sky) + static const blue = Color(0xFF7FCBF5); + static const blueBg = Color(0x1C7FCBF5); + static const blueLine = Color(0x477FCBF5); + + // Magenta + static const magenta = Color(0xFFDE7FDB); + static const magentaBg = Color(0x1CDE7FDB); + static const magentaLine = Color(0x47DE7FDB); + + // Me bubble (mossy) + static const me = Color(0xFF1E3527); + static const meBorder = Color(0xFF2D5039); + static const meInk = Color(0xFFDEF0DC); + + // ── Light variant (used when user explicitly picks light theme) + static const lightBg = Color(0xFFF5F3EC); + static const lightBg1 = Color(0xFFECE9DF); + static const lightBg2 = Color(0xFFE2DED2); + static const lightLine = Color(0xFFCAC5B4); + static const lightInk = Color(0xFF0F1410); + static const lightInk2 = Color(0xFF3D463E); + static const lightInk3 = Color(0xFF6A756D); + static const lightSignal = Color(0xFF1A7A44); +} + +/// Named font stacks — Flutter falls back to system fonts when the named +/// family isn't installed, keeping things working without bundled assets. +class MeshFonts { + MeshFonts._(); + + static const sans = 'Inter'; + static const mono = 'JetBrains Mono'; + static const display = 'Instrument Serif'; + + static const List sansFallback = [ + 'system-ui', + '-apple-system', + 'Roboto', + 'Noto Sans', + 'sans-serif', + ]; + static const List monoFallback = [ + 'SF Mono', + 'Menlo', + 'Consolas', + 'Roboto Mono', + 'monospace', + ]; + static const List displayFallback = [ + 'Cormorant Garamond', + 'Georgia', + 'Times New Roman', + 'serif', + ]; +} + +/// Radii used consistently across the app. +class MeshRadii { + MeshRadii._(); + static const xs = 6.0; + static const sm = 10.0; + static const md = 14.0; + static const lg = 18.0; + static const xl = 24.0; + static const pill = 999.0; +} + +/// Shared helpers exposed via [MeshTheme.of]. +class MeshTheme { + MeshTheme._(); + + static ThemeData dark() { + const scheme = ColorScheme.dark( + primary: MeshPalette.signal, + onPrimary: Color(0xFF0A1810), + primaryContainer: MeshPalette.signalBg, + onPrimaryContainer: MeshPalette.signal, + secondary: MeshPalette.blue, + onSecondary: Color(0xFF0A1520), + tertiary: MeshPalette.magenta, + onTertiary: Color(0xFF201020), + error: MeshPalette.alert, + onError: Color(0xFF1A0A08), + errorContainer: MeshPalette.alertBg, + onErrorContainer: MeshPalette.alert, + surface: MeshPalette.bg, + onSurface: MeshPalette.ink, + surfaceContainerLowest: MeshPalette.bg, + surfaceContainerLow: MeshPalette.bg1, + surfaceContainer: MeshPalette.bg1, + surfaceContainerHigh: MeshPalette.bg2, + surfaceContainerHighest: MeshPalette.bg3, + onSurfaceVariant: MeshPalette.ink2, + outline: MeshPalette.line2, + outlineVariant: MeshPalette.line, + shadow: Colors.black, + scrim: Colors.black54, + inverseSurface: MeshPalette.ink, + onInverseSurface: MeshPalette.bg, + inversePrimary: MeshPalette.signalDim, + ); + return _build(scheme, Brightness.dark); + } + + static ThemeData light() { + const scheme = ColorScheme.light( + primary: MeshPalette.lightSignal, + onPrimary: Colors.white, + primaryContainer: Color(0xFFD4E8D8), + onPrimaryContainer: MeshPalette.lightSignal, + secondary: Color(0xFF2F6EA8), + onSecondary: Colors.white, + tertiary: Color(0xFF8C4A8A), + onTertiary: Colors.white, + error: Color(0xFFB53D2F), + onError: Colors.white, + surface: MeshPalette.lightBg, + onSurface: MeshPalette.lightInk, + surfaceContainerLowest: MeshPalette.lightBg, + surfaceContainerLow: MeshPalette.lightBg1, + surfaceContainer: MeshPalette.lightBg1, + surfaceContainerHigh: MeshPalette.lightBg2, + surfaceContainerHighest: Color(0xFFD5D0C0), + onSurfaceVariant: MeshPalette.lightInk2, + outline: MeshPalette.lightLine, + outlineVariant: Color(0xFFDBD6C6), + ); + return _build(scheme, Brightness.light); + } + + static ThemeData _build(ColorScheme scheme, Brightness brightness) { + final baseText = + Typography.material2021( + platform: TargetPlatform.android, + colorScheme: scheme, + ).black.apply( + bodyColor: scheme.onSurface, + displayColor: scheme.onSurface, + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + ); + + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + canvasColor: scheme.surface, + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + textTheme: baseText, + dividerColor: scheme.outlineVariant, + dividerTheme: DividerThemeData( + color: scheme.outlineVariant, + thickness: 1, + space: 1, + ), + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: scheme.onSurface, + ), + iconTheme: IconThemeData(color: scheme.onSurface), + shape: Border( + bottom: BorderSide(color: scheme.outlineVariant, width: 1), + ), + ), + cardTheme: CardThemeData( + color: scheme.surfaceContainerLow, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + side: BorderSide(color: scheme.outlineVariant, width: 1), + ), + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0), + ), + listTileTheme: ListTileThemeData( + iconColor: scheme.onSurfaceVariant, + textColor: scheme.onSurface, + tileColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + elevation: 0, + focusElevation: 0, + hoverElevation: 0, + highlightElevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + extendedTextStyle: const TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + textStyle: const TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: scheme.onSurface, + side: BorderSide(color: scheme.outline), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: scheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHigh, + hintStyle: TextStyle(color: scheme.onSurfaceVariant), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + borderSide: BorderSide(color: scheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + borderSide: BorderSide(color: scheme.primary, width: 1.5), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: scheme.surfaceContainerLow, + side: BorderSide(color: scheme.outlineVariant), + labelStyle: TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: scheme.onSurfaceVariant, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: scheme.surfaceContainerLow, + surfaceTintColor: Colors.transparent, + indicatorColor: scheme.primary.withValues(alpha: 0.14), + indicatorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + ), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return TextStyle( + fontFamily: MeshFonts.mono, + fontFamilyFallback: MeshFonts.monoFallback, + fontSize: 10, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: 0.1, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return IconThemeData( + color: selected ? scheme.primary : scheme.onSurfaceVariant, + size: 22, + ); + }), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: scheme.surfaceContainerLow, + surfaceTintColor: Colors.transparent, + modalBackgroundColor: scheme.surfaceContainerLow, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(MeshRadii.lg), + ), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: scheme.surfaceContainerLow, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.lg), + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: scheme.surfaceContainerHigh, + contentTextStyle: TextStyle(color: scheme.onSurface), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + ), + ), + popupMenuTheme: PopupMenuThemeData( + color: scheme.surfaceContainerHigh, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.md), + ), + ), + iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22), + splashFactory: InkSparkle.splashFactory, + ); + } + + /// Mono text style — sizes default to the body size Inter is using. + static TextStyle mono({ + double? fontSize, + FontWeight? fontWeight, + Color? color, + double? letterSpacing, + }) { + return TextStyle( + fontFamily: MeshFonts.mono, + fontFamilyFallback: MeshFonts.monoFallback, + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + letterSpacing: letterSpacing ?? 0.2, + fontFeatures: const [FontFeature.tabularFigures()], + ); + } + + /// Serif display style. + static TextStyle display({ + double? fontSize, + FontWeight? fontWeight, + Color? color, + double? letterSpacing, + }) { + return TextStyle( + fontFamily: MeshFonts.display, + fontFamilyFallback: MeshFonts.displayFallback, + fontSize: fontSize, + fontWeight: fontWeight ?? FontWeight.w400, + color: color, + letterSpacing: letterSpacing ?? -0.2, + ); + } + + /// Small-caps mono label — used for section accents and chip labels. + static TextStyle accentLabel({Color? color, double? fontSize}) { + return TextStyle( + fontFamily: MeshFonts.mono, + fontFamilyFallback: MeshFonts.monoFallback, + fontSize: fontSize ?? 9.5, + fontWeight: FontWeight.w600, + letterSpacing: 1.8, + color: color, + ); + } + + /// Color-code an SNR value for consistency across the app. + static Color snrColor(num? snr, {required bool blocked}) { + if (blocked) return MeshPalette.alert; + if (snr == null) return MeshPalette.ink3; + if (snr > -5) return MeshPalette.signal; + if (snr > -12) return MeshPalette.warn; + return MeshPalette.alert; + } +} diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93e46829..379e36fa 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 533a1712..f02857f4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial flutter_local_notifications_windows - jni ) set(PLUGIN_BUNDLED_LIBRARIES) From e1d23ad2c7e18b61f255223092fcc99d77a8d67a Mon Sep 17 00:00:00 2001 From: zjs81 Date: Mon, 27 Apr 2026 13:09:10 -0700 Subject: [PATCH 6/6] style: dart format Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/screens/channel_chat_screen.dart | 8 ++------ lib/screens/chat_screen.dart | 12 +++++------- lib/screens/map_screen.dart | 16 ++++++++-------- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 9393a27a..4f7805bc 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -347,8 +347,7 @@ class _ChannelChatScreenState extends State { } final isUnreadAnchor = _unreadDividerMessageId != null && - message.messageId == - _unreadDividerMessageId; + message.messageId == _unreadDividerMessageId; return Container( key: _messageKeys[message.messageId]!, child: Builder( @@ -364,10 +363,7 @@ class _ChannelChatScreenState extends State { if (isUnreadAnchor) { return Column( mainAxisSize: MainAxisSize.min, - children: [ - const UnreadDivider(), - bubble, - ], + children: [const UnreadDivider(), bubble], ); } return bubble; diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 62268f03..3e49b819 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -514,7 +514,8 @@ class _ChatScreenState extends State { onRetryReaction: (msg, emoji) => _sendReaction(msg, contact, emoji), ); - final isUnreadAnchor = _unreadDividerMessageId != null && + final isUnreadAnchor = + _unreadDividerMessageId != null && message.messageId == _unreadDividerMessageId; final child = isUnreadAnchor ? Column( @@ -1354,16 +1355,13 @@ class _ChatScreenState extends State { void _openChat(BuildContext context, Contact contact) { final connector = context.read(); - final unread = - connector.getUnreadCountForContactKey(contact.publicKeyHex); + final unread = connector.getUnreadCountForContactKey(contact.publicKeyHex); connector.markContactRead(contact.publicKeyHex); Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen( - contact: contact, - initialUnreadCount: unread, - ), + builder: (context) => + ChatScreen(contact: contact, initialUnreadCount: unread), ), ); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 39d42a49..0997e214 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1392,16 +1392,15 @@ class _MapScreenState extends State { // onLogin(password, isAdmin) isAdmin not used for room caht screen onLogin: (password, _) { final connector = context.read(); - final unread = - connector.getUnreadCountForContactKey(room.publicKeyHex); + final unread = connector.getUnreadCountForContactKey( + room.publicKeyHex, + ); connector.markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen( - contact: room, - initialUnreadCount: unread, - ), + builder: (context) => + ChatScreen(contact: room, initialUnreadCount: unread), ), ); }, @@ -1463,8 +1462,9 @@ class _MapScreenState extends State { if (!contact.isActive) { connector.importDiscoveredContact(contact); } - final unread = - connector.getUnreadCountForContactKey(contact.publicKeyHex); + final unread = connector.getUnreadCountForContactKey( + contact.publicKeyHex, + ); Navigator.pop(dialogContext); Navigator.push( context,