Merge pull request #414 from Diadlo/fix/jump_to_unread

Improve work with unread messages
This commit is contained in:
zjs81
2026-04-27 13:11:00 -07:00
committed by GitHub
32 changed files with 917 additions and 45 deletions
+21
View File
@@ -658,6 +658,27 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
void setContactUnreadCount(String contactKeyHex, int count) {
_contactUnreadCount[contactKeyHex] = count;
_unreadStore.saveContactUnreadCount(
Map<String, int>.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) {
+19
View File
@@ -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) {
+2
View File
@@ -817,6 +817,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",
+12
View File
@@ -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:
+6
View File
@@ -1558,6 +1558,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 => 'Отваряне на връзката?';
+6
View File
@@ -1555,6 +1555,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?';
+6
View File
@@ -1525,6 +1525,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?';
+6
View File
@@ -1554,6 +1554,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?';
+6
View File
@@ -1563,6 +1563,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 ?';
+6
View File
@@ -1565,6 +1565,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?';
+6
View File
@@ -1556,6 +1556,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?';
+6
View File
@@ -1487,6 +1487,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 => 'リンクを開く?';
+6
View File
@@ -1483,6 +1483,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 => '링크를 열기?';
+6
View File
@@ -1543,6 +1543,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?';
+6
View File
@@ -1567,6 +1567,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?';
+6
View File
@@ -1554,6 +1554,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?';
+6
View File
@@ -1558,6 +1558,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 => 'Открыть ссылку?';
+6
View File
@@ -1544,6 +1544,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?';
+6
View File
@@ -1539,6 +1539,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?';
+6
View File
@@ -1533,6 +1533,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?';
+6
View File
@@ -1552,6 +1552,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 => 'Відкрити посилання?';
+6
View File
@@ -1455,6 +1455,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 => '打开链接?';
+65 -10
View File
@@ -32,13 +32,19 @@ 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';
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<ChannelChatScreen> createState() => _ChannelChatScreenState();
@@ -55,32 +61,42 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().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,
);
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;
_scrollToMessage(anchor!.messageId);
_scrollController.jumpToEstimatedOffset(
unreadCount: unread,
totalMessages: messages.length,
onJumped: () {
if (!mounted) return;
_scrollToMessage(anchor!.messageId);
},
);
});
}
});
@@ -102,6 +118,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return oldest;
}
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen();
@@ -123,6 +146,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override
void dispose() {
_connector?.setActiveChannel(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
@@ -321,6 +345,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
return Container(
key: _messageKeys[message.messageId]!,
child: Builder(
@@ -329,10 +356,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.select<ChatTextScaleService, double>(
(service) => service.scale,
);
return _buildMessageBubble(
final bubble = _buildMessageBubble(
message,
textScale,
);
if (isUnreadAnchor) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [const UnreadDivider(), bubble],
);
}
return bubble;
},
),
);
@@ -352,6 +386,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
void _markAsUnread(ChannelMessage message) {
final connector = context.read<MeshCoreConnector>();
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);
}
Widget _buildMessageBubble(ChannelMessage message, double textScale) {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
@@ -1288,6 +1334,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_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),
+6 -1
View File
@@ -492,13 +492,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
],
),
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,
),
),
);
}
+85 -23
View File
@@ -44,12 +44,18 @@ 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 {
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<ChatScreen> createState() => _ChatScreenState();
@@ -63,6 +69,7 @@ class _ChatScreenState extends State<ChatScreen> {
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
Message? _pendingUnreadScrollTarget;
String? _unreadDividerMessageId;
DateTime? _lastTextSendAt;
@override
@@ -70,34 +77,47 @@ class _ChatScreenState extends State<ChatScreen> {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().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,
);
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;
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);
}
},
);
});
}
});
@@ -116,6 +136,13 @@ class _ChatScreenState extends State<ChatScreen> {
return oldest;
}
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen();
@@ -137,6 +164,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void dispose() {
_connector?.setActiveContact(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
@@ -486,10 +514,19 @@ class _ChatScreenState extends State<ChatScreen> {
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;
},
);
},
@@ -497,6 +534,18 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
void _markAsUnread(Message message) {
final connector = context.read<MeshCoreConnector>();
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);
}
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme;
@@ -1320,11 +1369,15 @@ class _ChatScreenState extends State<ChatScreen> {
}
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
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),
),
);
}
@@ -1461,6 +1514,15 @@ class _ChatScreenState extends State<ChatScreen> {
_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),
+18 -4
View File
@@ -930,10 +930,18 @@ class _ContactsScreenState extends State<ContactsScreen>
} else if (contact.type == advTypeRoom) {
_showRoomLogin(context, contact, RoomLoginDestination.chat);
} else {
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
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<ContactsScreen>
builder: (context) => RoomLoginDialog(
room: room,
onLogin: (password, isAdmin) {
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
final unread =
connector.getUnreadCountForContactKey(room.publicKeyHex);
connector.markContactRead(room.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(
@@ -999,7 +1010,10 @@ class _ContactsScreenState extends State<ContactsScreen>
password: password,
isAdmin: isAdmin,
)
: ChatScreen(contact: room),
: ChatScreen(
contact: room,
initialUnreadCount: unread,
),
),
);
},
+16 -4
View File
@@ -1391,11 +1391,17 @@ class _MapScreenState extends State<MapScreen> {
room: room,
// onLogin(password, isAdmin) isAdmin not used for room caht screen
onLogin: (password, _) {
// Navigate to chat screen after successful login
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
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),
),
);
},
),
@@ -1456,11 +1462,17 @@ class _MapScreenState extends State<MapScreen> {
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,
),
),
);
},
+447
View File
@@ -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<String> sansFallback = [
'system-ui',
'-apple-system',
'Roboto',
'Noto Sans',
'sans-serif',
];
static const List<String> monoFallback = [
'SF Mono',
'Menlo',
'Consolas',
'Roboto Mono',
'monospace',
];
static const List<String> 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;
}
}
+32
View File
@@ -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)),
],
),
);
}
}
-1
View File
@@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
+86 -1
View File
@@ -1 +1,86 @@
{}
{
"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"
]
}
-1
View File
@@ -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)