mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-10 10:37:06 +10:00
Merge pull request #414 from Diadlo/fix/jump_to_unread
Improve work with unread messages
This commit is contained in:
@@ -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) {
|
void markChannelRead(int channelIndex) {
|
||||||
final channel = _findChannelByIndex(channelIndex);
|
final channel = _findChannelByIndex(channelIndex);
|
||||||
if (channel != null && channel.unreadCount > 0) {
|
if (channel != null && channel.unreadCount > 0) {
|
||||||
|
|||||||
@@ -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() {
|
void scrollToBottomIfAtBottom() {
|
||||||
// Only scroll if jump button is NOT showing (i.e., already at bottom)
|
// Only scroll if jump button is NOT showing (i.e., already at bottom)
|
||||||
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
|
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
|
||||||
|
|||||||
@@ -817,6 +817,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"chat_markAsUnread": "Mark as Unread",
|
||||||
|
"chat_newMessages": "New messages",
|
||||||
"chat_openLink": "Open Link?",
|
"chat_openLink": "Open Link?",
|
||||||
"chat_openLinkConfirmation": "Do you want to open this link in your browser?",
|
"chat_openLinkConfirmation": "Do you want to open this link in your browser?",
|
||||||
"chat_open": "Open",
|
"chat_open": "Open",
|
||||||
|
|||||||
@@ -2824,6 +2824,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Unread: {count}'**
|
/// **'Unread: {count}'**
|
||||||
String chat_unread(int 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.
|
/// No description provided for @chat_openLink.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -1558,6 +1558,12 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
return 'Непрочетени: $count';
|
return 'Непрочетени: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Отваряне на връзката?';
|
String get chat_openLink => 'Отваряне на връзката?';
|
||||||
|
|
||||||
|
|||||||
@@ -1555,6 +1555,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Ungelesen: $count';
|
return 'Ungelesen: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Link öffnen?';
|
String get chat_openLink => 'Link öffnen?';
|
||||||
|
|
||||||
|
|||||||
@@ -1525,6 +1525,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Unread: $count';
|
return 'Unread: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Open Link?';
|
String get chat_openLink => 'Open Link?';
|
||||||
|
|
||||||
|
|||||||
@@ -1554,6 +1554,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Sin leer: $count';
|
return 'Sin leer: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => '¿Abrir enlace?';
|
String get chat_openLink => '¿Abrir enlace?';
|
||||||
|
|
||||||
|
|||||||
@@ -1563,6 +1563,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Non lu : $count';
|
return 'Non lu : $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Ouvrir le lien ?';
|
String get chat_openLink => 'Ouvrir le lien ?';
|
||||||
|
|
||||||
|
|||||||
@@ -1565,6 +1565,12 @@ class AppLocalizationsHu extends AppLocalizations {
|
|||||||
return 'Olvasatlan: $count';
|
return 'Olvasatlan: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Nyisd meg a linket?';
|
String get chat_openLink => 'Nyisd meg a linket?';
|
||||||
|
|
||||||
|
|||||||
@@ -1556,6 +1556,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'Non letti: $count';
|
return 'Non letti: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Aprire il link?';
|
String get chat_openLink => 'Aprire il link?';
|
||||||
|
|
||||||
|
|||||||
@@ -1487,6 +1487,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '未読: $count';
|
return '未読: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'リンクを開く?';
|
String get chat_openLink => 'リンクを開く?';
|
||||||
|
|
||||||
|
|||||||
@@ -1483,6 +1483,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return '읽지 않음: $count';
|
return '읽지 않음: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => '링크를 열기?';
|
String get chat_openLink => '링크를 열기?';
|
||||||
|
|
||||||
|
|||||||
@@ -1543,6 +1543,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Nieuw: $count';
|
return 'Nieuw: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Link openen?';
|
String get chat_openLink => 'Link openen?';
|
||||||
|
|
||||||
|
|||||||
@@ -1567,6 +1567,12 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
return 'Nieprzeczytane: $count';
|
return 'Nieprzeczytane: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Otworzyć link?';
|
String get chat_openLink => 'Otworzyć link?';
|
||||||
|
|
||||||
|
|||||||
@@ -1554,6 +1554,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Não lido: $count';
|
return 'Não lido: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Abrir link?';
|
String get chat_openLink => 'Abrir link?';
|
||||||
|
|
||||||
|
|||||||
@@ -1558,6 +1558,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Непрочитанных: $count';
|
return 'Непрочитанных: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Открыть ссылку?';
|
String get chat_openLink => 'Открыть ссылку?';
|
||||||
|
|
||||||
|
|||||||
@@ -1544,6 +1544,12 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
return 'Nezriadené: $count';
|
return 'Nezriadené: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Otvoriť odkaz?';
|
String get chat_openLink => 'Otvoriť odkaz?';
|
||||||
|
|
||||||
|
|||||||
@@ -1539,6 +1539,12 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
return 'Nerešeno: $count';
|
return 'Nerešeno: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Odpreti povezavo?';
|
String get chat_openLink => 'Odpreti povezavo?';
|
||||||
|
|
||||||
|
|||||||
@@ -1533,6 +1533,12 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
return 'Olästa: $count';
|
return 'Olästa: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Öppna länk?';
|
String get chat_openLink => 'Öppna länk?';
|
||||||
|
|
||||||
|
|||||||
@@ -1552,6 +1552,12 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
return 'Непрочитано: $count';
|
return 'Непрочитано: $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => 'Відкрити посилання?';
|
String get chat_openLink => 'Відкрити посилання?';
|
||||||
|
|
||||||
|
|||||||
@@ -1455,6 +1455,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '未读:$count';
|
return '未读:$count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_markAsUnread => 'Mark as Unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chat_newMessages => 'New messages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chat_openLink => '打开链接?';
|
String get chat_openLink => '打开链接?';
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,19 @@ import '../widgets/message_translation_button.dart';
|
|||||||
import '../widgets/message_status_icon.dart';
|
import '../widgets/message_status_icon.dart';
|
||||||
import '../widgets/radio_stats_entry.dart';
|
import '../widgets/radio_stats_entry.dart';
|
||||||
import '../widgets/translated_message_content.dart';
|
import '../widgets/translated_message_content.dart';
|
||||||
|
import '../widgets/unread_divider.dart';
|
||||||
import 'channel_message_path_screen.dart';
|
import 'channel_message_path_screen.dart';
|
||||||
import 'map_screen.dart';
|
import 'map_screen.dart';
|
||||||
|
|
||||||
class ChannelChatScreen extends StatefulWidget {
|
class ChannelChatScreen extends StatefulWidget {
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
|
final int initialUnreadCount;
|
||||||
|
|
||||||
const ChannelChatScreen({super.key, required this.channel});
|
const ChannelChatScreen({
|
||||||
|
super.key,
|
||||||
|
required this.channel,
|
||||||
|
this.initialUnreadCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||||
@@ -55,32 +61,42 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
MeshCoreConnector? _connector;
|
MeshCoreConnector? _connector;
|
||||||
DateTime? _lastChannelSendAt;
|
DateTime? _lastChannelSendAt;
|
||||||
bool _channelSkipNextBottomSnap = false;
|
bool _channelSkipNextBottomSnap = false;
|
||||||
|
String? _unreadDividerMessageId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
||||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||||
|
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final settings = context.read<AppSettingsService>().settings;
|
final settings = context.read<AppSettingsService>().settings;
|
||||||
final idx = widget.channel.index;
|
final idx = widget.channel.index;
|
||||||
final unread = connector.getUnreadCountForChannelIndex(idx);
|
final unread = widget.initialUnreadCount;
|
||||||
|
final messages = connector.getChannelMessages(widget.channel);
|
||||||
ChannelMessage? anchor;
|
ChannelMessage? anchor;
|
||||||
if (settings.jumpToOldestUnread && unread > 0) {
|
if (unread > 0) {
|
||||||
anchor = _findOldestUnreadChannelAnchor(
|
anchor = _findOldestUnreadChannelAnchor(messages, unread);
|
||||||
connector.getChannelMessages(widget.channel),
|
|
||||||
unread,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
|
||||||
|
});
|
||||||
connector.setActiveChannel(idx);
|
connector.setActiveChannel(idx);
|
||||||
_connector = connector;
|
_connector = connector;
|
||||||
if (anchor != null) {
|
if (anchor != null && settings.jumpToOldestUnread) {
|
||||||
_channelSkipNextBottomSnap = true;
|
_channelSkipNextBottomSnap = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
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;
|
return oldest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _clearDividerAtBottom() {
|
||||||
|
if (!_scrollController.showJumpToBottom.value &&
|
||||||
|
_unreadDividerMessageId != null) {
|
||||||
|
setState(() => _unreadDividerMessageId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onTextFieldFocusChange() {
|
void _onTextFieldFocusChange() {
|
||||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||||
_scrollController.handleKeyboardOpen();
|
_scrollController.handleKeyboardOpen();
|
||||||
@@ -123,6 +146,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_connector?.setActiveChannel(null);
|
_connector?.setActiveChannel(null);
|
||||||
|
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
|
||||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||||
_textFieldFocusNode.dispose();
|
_textFieldFocusNode.dispose();
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
@@ -321,6 +345,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
if (!_messageKeys.containsKey(message.messageId)) {
|
if (!_messageKeys.containsKey(message.messageId)) {
|
||||||
_messageKeys[message.messageId] = GlobalKey();
|
_messageKeys[message.messageId] = GlobalKey();
|
||||||
}
|
}
|
||||||
|
final isUnreadAnchor =
|
||||||
|
_unreadDividerMessageId != null &&
|
||||||
|
message.messageId == _unreadDividerMessageId;
|
||||||
return Container(
|
return Container(
|
||||||
key: _messageKeys[message.messageId]!,
|
key: _messageKeys[message.messageId]!,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
@@ -329,10 +356,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
.select<ChatTextScaleService, double>(
|
.select<ChatTextScaleService, double>(
|
||||||
(service) => service.scale,
|
(service) => service.scale,
|
||||||
);
|
);
|
||||||
return _buildMessageBubble(
|
final bubble = _buildMessageBubble(
|
||||||
message,
|
message,
|
||||||
textScale,
|
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) {
|
Widget _buildMessageBubble(ChannelMessage message, double textScale) {
|
||||||
final settingsService = context.watch<AppSettingsService>();
|
final settingsService = context.watch<AppSettingsService>();
|
||||||
final enableTracing = settingsService.settings.enableMessageTracing;
|
final enableTracing = settingsService.settings.enableMessageTracing;
|
||||||
@@ -1288,6 +1334,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
_copyMessageText(message.text);
|
_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(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: Text(context.l10n.common_delete),
|
title: Text(context.l10n.common_delete),
|
||||||
|
|||||||
@@ -492,13 +492,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
final unread =
|
||||||
|
connector.getUnreadCountForChannelIndex(channel.index);
|
||||||
connector.markChannelRead(channel.index);
|
connector.markChannelRead(channel.index);
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ChannelChatScreen(channel: channel),
|
builder: (context) => ChannelChatScreen(
|
||||||
|
channel: channel,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,12 +44,18 @@ import '../widgets/translated_message_content.dart';
|
|||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
import '../widgets/unread_divider.dart';
|
||||||
import 'telemetry_screen.dart';
|
import 'telemetry_screen.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
|
final int initialUnreadCount;
|
||||||
|
|
||||||
const ChatScreen({super.key, required this.contact});
|
const ChatScreen({
|
||||||
|
super.key,
|
||||||
|
required this.contact,
|
||||||
|
this.initialUnreadCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
@@ -63,6 +69,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
bool _isLoadingOlder = false;
|
bool _isLoadingOlder = false;
|
||||||
MeshCoreConnector? _connector;
|
MeshCoreConnector? _connector;
|
||||||
Message? _pendingUnreadScrollTarget;
|
Message? _pendingUnreadScrollTarget;
|
||||||
|
String? _unreadDividerMessageId;
|
||||||
DateTime? _lastTextSendAt;
|
DateTime? _lastTextSendAt;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -70,34 +77,47 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
||||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||||
|
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final settings = context.read<AppSettingsService>().settings;
|
final settings = context.read<AppSettingsService>().settings;
|
||||||
final keyHex = widget.contact.publicKeyHex;
|
final keyHex = widget.contact.publicKeyHex;
|
||||||
final unread = connector.getUnreadCountForContactKey(keyHex);
|
final unread = widget.initialUnreadCount;
|
||||||
|
final messages = connector.getMessages(widget.contact);
|
||||||
Message? anchor;
|
Message? anchor;
|
||||||
if (settings.jumpToOldestUnread && unread > 0) {
|
if (unread > 0) {
|
||||||
anchor = _findOldestUnreadAnchor(
|
anchor = _findOldestUnreadAnchor(messages, unread);
|
||||||
connector.getMessages(widget.contact),
|
|
||||||
unread,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
|
||||||
|
if (anchor != null && settings.jumpToOldestUnread) {
|
||||||
|
_pendingUnreadScrollTarget = anchor;
|
||||||
|
}
|
||||||
|
});
|
||||||
connector.setActiveContact(keyHex);
|
connector.setActiveContact(keyHex);
|
||||||
_connector = connector;
|
_connector = connector;
|
||||||
if (anchor != null) {
|
if (anchor != null && settings.jumpToOldestUnread) {
|
||||||
setState(() => _pendingUnreadScrollTarget = anchor);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ctx = _unreadScrollKey.currentContext;
|
_scrollController.jumpToEstimatedOffset(
|
||||||
if (ctx != null) {
|
unreadCount: unread,
|
||||||
Scrollable.ensureVisible(
|
totalMessages: messages.length,
|
||||||
ctx,
|
onJumped: () async {
|
||||||
duration: const Duration(milliseconds: 350),
|
if (!mounted) return;
|
||||||
alignment: 0.15,
|
final ctx = _unreadScrollKey.currentContext;
|
||||||
);
|
if (ctx != null) {
|
||||||
}
|
await Scrollable.ensureVisible(
|
||||||
setState(() => _pendingUnreadScrollTarget = null);
|
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;
|
return oldest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _clearDividerAtBottom() {
|
||||||
|
if (!_scrollController.showJumpToBottom.value &&
|
||||||
|
_unreadDividerMessageId != null) {
|
||||||
|
setState(() => _unreadDividerMessageId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onTextFieldFocusChange() {
|
void _onTextFieldFocusChange() {
|
||||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||||
_scrollController.handleKeyboardOpen();
|
_scrollController.handleKeyboardOpen();
|
||||||
@@ -137,6 +164,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_connector?.setActiveContact(null);
|
_connector?.setActiveContact(null);
|
||||||
|
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
|
||||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||||
_textFieldFocusNode.dispose();
|
_textFieldFocusNode.dispose();
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
@@ -486,10 +514,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
onRetryReaction: (msg, emoji) =>
|
onRetryReaction: (msg, emoji) =>
|
||||||
_sendReaction(msg, contact, 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)) {
|
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) {
|
Widget _buildInputBar(MeshCoreConnector connector) {
|
||||||
final maxBytes = maxContactMessageBytes();
|
final maxBytes = maxContactMessageBytes();
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -1320,11 +1369,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openChat(BuildContext context, Contact contact) {
|
void _openChat(BuildContext context, Contact contact) {
|
||||||
// Check if this is a repeater
|
final connector = context.read<MeshCoreConnector>();
|
||||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
final unread = connector.getUnreadCountForContactKey(contact.publicKeyHex);
|
||||||
|
connector.markContactRead(contact.publicKeyHex);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
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);
|
_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(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: Text(context.l10n.common_delete),
|
title: Text(context.l10n.common_delete),
|
||||||
|
|||||||
@@ -930,10 +930,18 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
} else if (contact.type == advTypeRoom) {
|
} else if (contact.type == advTypeRoom) {
|
||||||
_showRoomLogin(context, contact, RoomLoginDestination.chat);
|
_showRoomLogin(context, contact, RoomLoginDestination.chat);
|
||||||
} else {
|
} else {
|
||||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final unread =
|
||||||
|
connector.getUnreadCountForContactKey(contact.publicKeyHex);
|
||||||
|
connector.markContactRead(contact.publicKeyHex);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
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(
|
builder: (context) => RoomLoginDialog(
|
||||||
room: room,
|
room: room,
|
||||||
onLogin: (password, isAdmin) {
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -999,7 +1010,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
password: password,
|
password: password,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
)
|
)
|
||||||
: ChatScreen(contact: room),
|
: ChatScreen(
|
||||||
|
contact: room,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1391,11 +1391,17 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
room: room,
|
room: room,
|
||||||
// onLogin(password, isAdmin) isAdmin not used for room caht screen
|
// onLogin(password, isAdmin) isAdmin not used for room caht screen
|
||||||
onLogin: (password, _) {
|
onLogin: (password, _) {
|
||||||
// Navigate to chat screen after successful login
|
final connector = context.read<MeshCoreConnector>();
|
||||||
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
final unread = connector.getUnreadCountForContactKey(
|
||||||
|
room.publicKeyHex,
|
||||||
|
);
|
||||||
|
connector.markContactRead(room.publicKeyHex);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
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) {
|
if (!contact.isActive) {
|
||||||
connector.importDiscoveredContact(contact);
|
connector.importDiscoveredContact(contact);
|
||||||
}
|
}
|
||||||
|
final unread = connector.getUnreadCountForContactKey(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
);
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ChatScreen(contact: contact),
|
builder: (context) => ChatScreen(
|
||||||
|
contact: contact,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
flserial
|
flserial
|
||||||
jni
|
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
+86
-1
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
flserial
|
flserial
|
||||||
flutter_local_notifications_windows
|
flutter_local_notifications_windows
|
||||||
jni
|
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user