Merge remote-tracking branch 'origin/main' into pr-94

This commit is contained in:
zjs81 2026-01-24 00:42:29 -07:00
commit 422ca941c2
13 changed files with 4606 additions and 204 deletions

File diff suppressed because one or more lines are too long

View file

@ -146,6 +146,7 @@ class MeshCoreConnector extends ChangeNotifier {
final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {};
final Map<int, int> _channelLastReadMs = {};
bool _unreadStateLoaded = false;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey;
int? _activeChannelIndex;
@ -317,6 +318,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
int getUnreadCountForContactKey(String contactKeyHex) {
if (!_unreadStateLoaded) return 0;
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0;
final messages = _conversations[contactKeyHex];
if (messages == null || messages.isEmpty) return 0;
@ -336,6 +338,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
int getUnreadCountForChannelIndex(int channelIndex) {
if (!_unreadStateLoaded) return 0;
final messages = _channelMessages[channelIndex];
if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _channelLastReadMs[channelIndex] ?? 0;
@ -350,6 +353,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
int getTotalUnreadCount() {
if (!_unreadStateLoaded) return 0;
var total = 0;
// Count unread contact messages
for (final contact in _contacts) {
@ -381,6 +385,7 @@ class MeshCoreConnector extends ChangeNotifier {
_channelLastReadMs
..clear()
..addAll(await _unreadStore.loadChannelLastRead());
_unreadStateLoaded = true;
notifyListeners();
}
@ -620,6 +625,17 @@ class MeshCoreConnector extends ChangeNotifier {
_scanResults.clear();
_setState(MeshCoreConnectionState.scanning);
// Ensure any previous scan is fully stopped
await FlutterBluePlus.stopScan();
await _scanSubscription?.cancel();
// On iOS/macOS, add a small delay to allow BLE stack to reset
// This prevents cached results from interfering with new scans
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
await Future.delayed(const Duration(milliseconds: 300));
}
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
_scanResults.clear();
for (var result in results) {

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
class ChatScrollController extends ScrollController {
final ValueNotifier<bool> showJumpToBottom = ValueNotifier(false);
VoidCallback? onScrollNearTop;
static const _bottomThreshold = 100.0;
static const _topThreshold = 50.0;
ChatScrollController() {
addListener(_handleScroll);
}
void _handleScroll() {
if (!hasClients) return;
final pos = position;
// With reverse: true, position 0 is bottom, maxScrollExtent is top
// Show jump button when scrolled away from bottom (position > threshold)
final isAtBottom = pos.pixels <= _bottomThreshold;
if (showJumpToBottom.value == isAtBottom) {
showJumpToBottom.value = !isAtBottom;
}
// Pagination trigger when scrolled near top (maxScrollExtent)
if (pos.pixels >= pos.maxScrollExtent - _topThreshold) {
onScrollNearTop?.call();
}
}
void jumpToBottom() {
if (hasClients && position.maxScrollExtent > 0) {
animateTo(
0, // With reverse: true, position 0 is bottom
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
void handleKeyboardOpen() {
// Simple: just scroll to bottom when keyboard opens
if (hasClients) {
animateTo(
0, // With reverse: true, position 0 is bottom
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
void scrollToBottomIfAtBottom() {
// Only scroll if jump button is NOT showing (i.e., already at bottom)
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
animateTo(
0, // With reverse: true, position 0 is bottom
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
@override
void dispose() {
showJumpToBottom.dispose();
super.dispose();
}
}

View file

@ -14,6 +14,7 @@ import 'app_localizations_it.dart';
import 'app_localizations_nl.dart';
import 'app_localizations_pl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_sk.dart';
import 'app_localizations_sl.dart';
import 'app_localizations_sv.dart';
@ -114,6 +115,7 @@ abstract class AppLocalizations {
Locale('nl'),
Locale('pl'),
Locale('pt'),
Locale('ru'),
Locale('sk'),
Locale('sl'),
Locale('sv'),
@ -4705,6 +4707,7 @@ class _AppLocalizationsDelegate
'nl',
'pl',
'pt',
'ru',
'sk',
'sl',
'sv',
@ -4736,6 +4739,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsPl();
case 'pt':
return AppLocalizationsPt();
case 'ru':
return AppLocalizationsRu();
case 'sk':
return AppLocalizationsSk();
case 'sl':

File diff suppressed because it is too large Load diff

778
lib/l10n/app_ru.arb Normal file
View file

@ -0,0 +1,778 @@
{
"@@locale": "ru",
"appTitle": "MeshCore Open",
"nav_contacts": "Контакты",
"nav_channels": "Каналы",
"nav_map": "Карта",
"common_cancel": "Отмена",
"common_ok": "OK",
"common_connect": "Коннект",
"common_unknownDevice": "Неизвестное устройство",
"common_save": "Сохранить",
"common_delete": "Удалить",
"common_close": "Закрыть",
"common_edit": "Изменить",
"common_add": "Добавить",
"common_settings": "Настройки",
"common_disconnect": "Отключить",
"common_connected": "Подключено",
"common_disconnected": "Отключено",
"common_create": "Создать",
"common_continue": "Продолжить",
"common_share": "Поделиться",
"common_copy": "Копировать",
"common_retry": "Повторить",
"common_hide": "Скрыть",
"common_remove": "Убрать",
"common_enable": "Включить",
"common_disable": "Выключить",
"common_reboot": "Перезагрузить",
"common_loading": "Загрузка...",
"common_notAvailable": "—",
"common_voltageValue": "{volts} В",
"common_percentValue": "{percent}%",
"scanner_title": "MeshCore Open",
"scanner_scanning": "Поиск устройств...",
"scanner_connecting": "Подключение...",
"scanner_disconnecting": "Отключение...",
"scanner_notConnected": "Не подключено",
"scanner_connectedTo": "Подключено к {deviceName}",
"scanner_searchingDevices": "Поиск устройств MeshCore...",
"scanner_tapToScan": "Нажмите для поиска MeshCore устройств",
"scanner_connectionFailed": "Подключение не удалось: {error}",
"scanner_stop": "Стоп",
"scanner_scan": "Сканирование",
"device_quickSwitch": "Быстрое переключение",
"device_meshcore": "MeshCore",
"settings_title": "Настройки",
"settings_deviceInfo": "Информация об устройстве",
"settings_appSettings": "Настройки приложения",
"settings_appSettingsSubtitle": "Уведомления, сообщения и настройки карты",
"settings_nodeSettings": "Настройки ноды",
"settings_nodeName": "Имя ноды",
"settings_nodeNameNotSet": "Не установлено",
"settings_nodeNameHint": "Введите имя ноды",
"settings_nodeNameUpdated": "Имя обновлено",
"settings_radioSettings": "Настройки радио",
"settings_radioSettingsSubtitle": "Частота, мощность и коэффициент распространения",
"settings_radioSettingsUpdated": "Настройки радио обновлены",
"settings_location": "Позиция",
"settings_locationSubtitle": "Координаты GPS",
"settings_locationUpdated": "Позиция и настройки GPS обновлены",
"settings_locationBothRequired": "Введите широту и долготу.",
"settings_locationInvalid": "Неверная широта или долгота.",
"settings_locationGPSEnable": "Включить GPS",
"settings_locationGPSEnableSubtitle": "Включение GPS для автоматического обновления позиции.",
"settings_locationIntervalSec": "Интервал для позиционирования GPS (секунды)",
"settings_locationIntervalInvalid": "Интервал должен составлять не менее 60 секунд и не более 86400 секунд.",
"settings_latitude": "Широта",
"settings_longitude": "Долгота",
"settings_privacyMode": "Режим конфиденциальности",
"settings_privacyModeSubtitle": "Скрыть имя/позицию в анонсировании",
"settings_privacyModeToggle": "Включите режим конфиденциальности, чтобы скрыть свое имя и местоположение в анонсировании.",
"settings_privacyModeEnabled": "Режим конфиденциальности включен",
"settings_privacyModeDisabled": "Режим конфиденциальности выключен",
"settings_actions": "Действия",
"settings_sendAdvertisement": "Отправить анонсирование",
"settings_sendAdvertisementSubtitle": "Отправить анонсирование о присутствии сейчас",
"settings_advertisementSent": "Анонсирование отправлено",
"settings_syncTime": "Синхронизация времени",
"settings_syncTimeSubtitle": "Синхронизировать время с телефоном",
"settings_timeSynchronized": "Время синхронизировано",
"settings_refreshContacts": "Обновить контакты",
"settings_refreshContactsSubtitle": "Перезагрузить список контактов с устройства",
"settings_rebootDevice": "Перезагрузить устройство",
"settings_rebootDeviceSubtitle": "Перезапустить устройство MeshCore",
"settings_rebootDeviceConfirm": "Вы уверены, что хотите перезагрузить устройство? Вы будете отключены.",
"settings_debug": "Отладка",
"settings_bleDebugLog": "Журнал отладки BLE",
"settings_bleDebugLogSubtitle": "Команды BLE, ответы и сырые данные",
"settings_appDebugLog": "Журнал отладки приложения",
"settings_appDebugLogSubtitle": "Сообщения отладки приложения",
"settings_about": "О программе",
"settings_aboutVersion": "MeshCore Open v{version}",
"settings_aboutLegalese": "2026 MeshCore Open Source Project",
"settings_aboutDescription": "Открытое клиентское приложение на Flutter для устройств MeshCore с LoRa-сетями.",
"settings_infoName": "Имя",
"settings_infoId": "ID",
"settings_infoStatus": "Статус",
"settings_infoBattery": "Батарея",
"settings_infoPublicKey": "Публичный ключ",
"settings_infoContactsCount": "Количество контактов",
"settings_infoChannelCount": "Количество каналов",
"settings_presets": "Пресеты",
"settings_preset915Mhz": "915 МГц",
"settings_preset868Mhz": "868 МГц",
"settings_preset433Mhz": "433 МГц",
"settings_frequency": "Частота (МГц)",
"settings_frequencyHelper": "300.0 2500.0",
"settings_frequencyInvalid": "Недопустимая частота (3002500 МГц)",
"settings_bandwidth": "Полоса пропускания",
"settings_spreadingFactor": "Коэффициент расширения",
"settings_codingRate": "Коэффициент кодирования",
"settings_txPower": "Мощность передачи (дБм)",
"settings_txPowerHelper": "0 22",
"settings_txPowerInvalid": "Недопустимая мощность передачи (022 дБм)",
"settings_longRange": "Дальний радиус",
"settings_fastSpeed": "Высокая скорость",
"settings_error": "Ошибка: {message}",
"appSettings_title": "Настройки приложения",
"appSettings_appearance": "Внешний вид",
"appSettings_theme": "Тема",
"appSettings_themeSystem": "Как в системе",
"appSettings_themeLight": "Светлая",
"appSettings_themeDark": "Тёмная",
"appSettings_language": "Язык",
"appSettings_languageSystem": "Как в системе",
"appSettings_languageEn": "Английский",
"appSettings_languageFr": "Французский",
"appSettings_languageEs": "Испанский",
"appSettings_languageDe": "Немецкий",
"appSettings_languagePl": "Польский",
"appSettings_languageSl": "Словенский",
"appSettings_languagePt": "Португальский",
"appSettings_languageIt": "Итальянский",
"appSettings_languageZh": "Китайский",
"appSettings_languageSv": "Шведский",
"appSettings_languageNl": "Нидерландский",
"appSettings_languageSk": "Словацкий",
"appSettings_languageBg": "Болгарский",
"appSettings_languageRu": "Русский",
"appSettings_notifications": "Уведомления",
"appSettings_enableNotifications": "Включить уведомления",
"appSettings_enableNotificationsSubtitle": "Получать уведомления о сообщениях и оповещениях",
"appSettings_notificationPermissionDenied": "Разрешение на уведомления отклонено",
"appSettings_notificationsEnabled": "Уведомления включены",
"appSettings_notificationsDisabled": "Уведомления отключены",
"appSettings_messageNotifications": "Уведомления о сообщениях",
"appSettings_messageNotificationsSubtitle": "Показывать уведомление при получении новых сообщений",
"appSettings_channelMessageNotifications": "Уведомления о сообщениях в каналах",
"appSettings_channelMessageNotificationsSubtitle": "Показывать уведомление при получении сообщений в каналах",
"appSettings_advertisementNotifications": "Уведомления об анонсированиях",
"appSettings_advertisementNotificationsSubtitle": "Показывать уведомление при обнаружении новых нод",
"appSettings_messaging": "Обмен сообщениями",
"appSettings_clearPathOnMaxRetry": "Сбросить маршрут после максимального числа попыток",
"appSettings_clearPathOnMaxRetrySubtitle": "Сбросить маршрут контакта после 5 неудачных попыток отправки",
"appSettings_pathsWillBeCleared": "Маршруты будут сброшены после 5 неудачных попыток",
"appSettings_pathsWillNotBeCleared": "Маршруты не будут автоматически сбрасываться",
"appSettings_autoRouteRotation": "Автоматическое переключение маршрутов",
"appSettings_autoRouteRotationSubtitle": "Циклически переключаться между лучшими маршрутами и режимом рассылки",
"appSettings_autoRouteRotationEnabled": "Автоматическое переключение маршрутов включено",
"appSettings_autoRouteRotationDisabled": "Автоматическое переключение маршрутов отключено",
"appSettings_battery": "Батарея",
"appSettings_batteryChemistry": "Химия батареи",
"appSettings_batteryChemistryPerDevice": "Установить для устройства ({deviceName})",
"appSettings_batteryChemistryConnectFirst": "Подключитесь к устройству, чтобы выбрать",
"appSettings_batteryNmc": "18650 NMC (3.04.2 В)",
"appSettings_batteryLifepo4": "LiFePO4 (2.63.65 В)",
"appSettings_batteryLipo": "LiPo (3.04.2 В)",
"appSettings_mapDisplay": "Отображение карты",
"appSettings_showRepeaters": "Показывать репитеры",
"appSettings_showRepeatersSubtitle": "Отображать репитеры на карте",
"appSettings_showChatNodes": "Показывать чат-ноды",
"appSettings_showChatNodesSubtitle": "Отображать чат-ноды на карте",
"appSettings_showOtherNodes": "Показывать другие ноды",
"appSettings_showOtherNodesSubtitle": "Отображать другие типы нод на карте",
"appSettings_timeFilter": "Фильтр по времени",
"appSettings_timeFilterShowAll": "Показывать все ноды",
"appSettings_timeFilterShowLast": "Показывать ноды за последние {hours} ч",
"appSettings_mapTimeFilter": "Временной фильтр карты",
"appSettings_showNodesDiscoveredWithin": "Показывать ноды, обнаруженные за:",
"appSettings_allTime": "Всё время",
"appSettings_lastHour": "Последний час",
"appSettings_last6Hours": "Последние 6 часов",
"appSettings_last24Hours": "Последние 24 часа",
"appSettings_lastWeek": "Последнюю неделю",
"appSettings_offlineMapCache": "Кэш офлайн-карты",
"appSettings_noAreaSelected": "Область не выбрана",
"appSettings_areaSelectedZoom": "Область выбрана (масштаб {minZoom}{maxZoom})",
"appSettings_debugCard": "Отладка",
"appSettings_appDebugLogging": "Журнал отладки приложения",
"appSettings_appDebugLoggingSubtitle": "Записывать отладочные сообщения приложения для диагностики",
"appSettings_appDebugLoggingEnabled": "Журнал отладки приложения включён",
"appSettings_appDebugLoggingDisabled": "Журнал отладки приложения отключён",
"contacts_title": "Контакты",
"contacts_noContacts": "Контактов пока нет",
"contacts_contactsWillAppear": "Контакты появятся, когда устройства начнут рассылать оповещения",
"contacts_searchContacts": "Поиск контактов...",
"contacts_noUnreadContacts": "Нет непрочитанных контактов",
"contacts_noContactsFound": "Контакты или группы не найдены",
"contacts_deleteContact": "Удалить контакт",
"contacts_removeConfirm": "Удалить {contactName} из контактов?",
"contacts_manageRepeater": "Управление репитером",
"contacts_manageRoom": "Управление сервером комнат",
"contacts_roomLogin": "Вход на сервер комнат",
"contacts_openChat": "Открыть чат",
"contacts_editGroup": "Изменить группу",
"contacts_deleteGroup": "Удалить группу",
"contacts_deleteGroupConfirm": "Удалить \"{groupName}\"?",
"contacts_newGroup": "Новая группа",
"contacts_groupName": "Имя группы",
"contacts_groupNameRequired": "Имя группы обязательно",
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
"contacts_filterContacts": "Фильтр контактов...",
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
"contacts_noMembers": "Нет участников",
"contacts_lastSeenNow": "Видели только что",
"contacts_lastSeenMinsAgo": "Видели {minutes} мин назад",
"contacts_lastSeenHourAgo": "Видели 1 час назад",
"contacts_lastSeenHoursAgo": "Видели {hours} ч назад",
"contacts_lastSeenDayAgo": "Видели 1 день назад",
"contacts_lastSeenDaysAgo": "Видели {days} дн. назад",
"channels_title": "Каналы",
"channels_noChannelsConfigured": "Каналы не настроены",
"channels_addPublicChannel": "Добавить публичный канал",
"channels_searchChannels": "Поиск каналов...",
"channels_noChannelsFound": "Каналы не найдены",
"channels_channelIndex": "Канал {index}",
"channels_hashtagChannel": "Хэштег-канал",
"channels_public": "Публичный",
"channels_private": "Приватный",
"channels_publicChannel": "Публичный канал",
"channels_privateChannel": "Приватный канал",
"channels_editChannel": "Изменить канал",
"channels_deleteChannel": "Удалить канал",
"channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.",
"channels_channelDeleted": "Канал \"{name}\" удалён",
"channels_addChannel": "Добавить канал",
"channels_channelIndexLabel": "Индекс канала",
"channels_channelName": "Имя канала",
"channels_usePublicChannel": "Использовать публичный канал",
"channels_standardPublicPsk": "Стандартный публичный PSK",
"channels_pskHex": "PSK (Hex)",
"channels_generateRandomPsk": "Сгенерировать случайный PSK",
"channels_enterChannelName": "Введите имя канала",
"channels_pskMustBe32Hex": "PSK должен содержать 32 шестнадцатеричных символа",
"channels_channelAdded": "Канал \"{name}\" добавлен",
"channels_editChannelTitle": "Изменить канал {index}",
"channels_smazCompression": "Сжатие SMAZ",
"channels_channelUpdated": "Канал \"{name}\" обновлён",
"channels_publicChannelAdded": "Публичный канал добавлен",
"channels_sortBy": "Сортировка",
"channels_sortManual": "Вручную",
"channels_sortAZ": "По алфавиту",
"channels_sortLatestMessages": "По последним сообщениям",
"channels_sortUnread": "По непрочитанным",
"channels_createPrivateChannel": "Создать приватный канал",
"channels_createPrivateChannelDesc": "Защищён секретным ключом.",
"channels_joinPrivateChannel": "Присоединиться к приватному каналу",
"channels_joinPrivateChannelDesc": "Введите секретный ключ вручную.",
"channels_joinPublicChannel": "Присоединиться к публичному каналу",
"channels_joinPublicChannelDesc": "К этому каналу может присоединиться любой.",
"channels_joinHashtagChannel": "Присоединиться к хэштег-каналу",
"channels_joinHashtagChannelDesc": "К хэштег-каналам может присоединиться любой.",
"channels_scanQrCode": "Сканировать QR-код",
"channels_scanQrCodeComingSoon": "Скоро будет",
"channels_enterHashtag": "Введите хэштег",
"channels_hashtagHint": "например, #команда",
"chat_noMessages": "Сообщений пока нет",
"chat_sendMessageToStart": "Отправьте сообщение, чтобы начать",
"chat_originalMessageNotFound": "Исходное сообщение не найдено",
"chat_replyingTo": "Ответ для {name}",
"chat_replyTo": "Ответить {name}",
"chat_location": "Местоположение",
"chat_sendMessageTo": "Отправить сообщение {contactName}",
"chat_typeMessage": "Напишите сообщение...",
"chat_messageTooLong": "Сообщение слишком длинное (макс. {maxBytes} байт).",
"chat_messageCopied": "Сообщение скопировано",
"chat_messageDeleted": "Сообщение удалено",
"chat_retryingMessage": "Повтор отправки сообщения",
"chat_retryCount": "Попытка {current}/{max}",
"chat_sendGif": "Отправить GIF",
"chat_reply": "Ответить",
"chat_addReaction": "Добавить реакцию",
"chat_me": "Я",
"emojiCategorySmileys": "Смайлы",
"emojiCategoryGestures": "Жесты",
"emojiCategoryHearts": "Сердечки",
"emojiCategoryObjects": "Предметы",
"gifPicker_title": "Выберите GIF",
"gifPicker_searchHint": "Поиск GIF...",
"gifPicker_poweredBy": "Работает на GIPHY",
"gifPicker_noGifsFound": "GIF не найдены",
"gifPicker_failedLoad": "Не удалось загрузить GIF",
"gifPicker_failedSearch": "Не удалось выполнить поиск GIF",
"gifPicker_noInternet": "Нет подключения к интернету",
"debugLog_appTitle": "Журнал отладки приложения",
"debugLog_bleTitle": "Журнал отладки BLE",
"debugLog_copyLog": "Копировать журнал",
"debugLog_clearLog": "Очистить журнал",
"debugLog_copied": "Журнал отладки скопирован",
"debugLog_bleCopied": "Журнал BLE скопирован",
"debugLog_noEntries": "Журнал отладки пока пуст",
"debugLog_enableInSettings": "Включите запись журнала отладки в настройках",
"debugLog_frames": "Фреймы",
"debugLog_rawLogRx": "Сырой журнал приёма",
"debugLog_noBleActivity": "Активность BLE пока отсутствует",
"debugFrame_length": "Длина фрейма: {count} байт",
"debugFrame_command": "Команда: 0x{value}",
"debugFrame_textMessageHeader": "Фрейм текстового сообщения:",
"debugFrame_destinationPubKey": "- Публичный ключ получателя: {pubKey}",
"debugFrame_timestamp": "- Временная метка: {timestamp}",
"debugFrame_flags": "- Флаги: 0x{value}",
"debugFrame_textType": "- Тип текста: {type} ({label})",
"debugFrame_textTypeCli": "CLI",
"debugFrame_textTypePlain": "Обычный",
"debugFrame_text": "- Текст: \"{text}\"",
"debugFrame_hexDump": "Шестнадцатеричный дамп:",
"chat_pathManagement": "Управление маршрутами",
"chat_routingMode": "Режим маршрутизации",
"chat_autoUseSavedPath": "Авто (использовать сохранённый маршрут)",
"chat_forceFloodMode": "Принудительный режим рассылки",
"chat_recentAckPaths": "Недавние подтверждённые маршруты (нажмите, чтобы использовать):",
"chat_pathHistoryFull": "История маршрутов заполнена. Удалите записи, чтобы добавить новые.",
"chat_hopSingular": "хоп",
"chat_hopPlural": "хопов",
"chat_hopsCount": "{count} {count, plural, one{хоп} few{хопа} many{хопов} other{хопов}}",
"chat_successes": "успешно",
"chat_removePath": "Удалить маршрут",
"chat_noPathHistoryYet": "История маршрутов пока пуста.\nОтправьте сообщение, чтобы обнаружить маршруты.",
"chat_pathActions": "Действия с маршрутом:",
"chat_setCustomPath": "Указать маршрут вручную",
"chat_setCustomPathSubtitle": "Вручную задать маршрут передачи",
"chat_clearPath": "Очистить маршрут",
"chat_clearPathSubtitle": "Принудительно обновить маршрут при следующей отправке",
"chat_pathCleared": "Маршрут очищен. Следующее сообщение обновит маршрут.",
"chat_floodModeSubtitle": "Используйте переключатель маршрутизации в панели приложения",
"chat_floodModeEnabled": "Режим рассылки включён. Отключите через значок маршрутизации в панели приложения.",
"chat_fullPath": "Полный маршрут",
"chat_pathDetailsNotAvailable": "Детали маршрута ещё недоступны. Попробуйте отправить сообщение для обновления.",
"chat_pathSetHops": "Маршрут установлен: {hopCount} {hopCount, plural, one{хоп} few{хопа} many{хопов} other{хопов}} — {status}",
"chat_pathSavedLocally": "Сохранено локально. Подключитесь для синхронизации.",
"chat_pathDeviceConfirmed": "Подтверждено устройством.",
"chat_pathDeviceNotConfirmed": "Ещё не подтверждено устройством.",
"chat_type": "Тип",
"chat_path": "Маршрут",
"chat_publicKey": "Публичный ключ",
"chat_compressOutgoingMessages": "Сжимать исходящие сообщения",
"chat_floodForced": "Рассылка (принудительно)",
"chat_directForced": "Прямой (принудительно)",
"chat_hopsForced": "{count} хоп(ов) (принудительно)",
"chat_floodAuto": "Рассылка (авто)",
"chat_direct": "Прямой",
"chat_poiShared": "Точка интереса отправлена",
"chat_unread": "Непрочитанных: {count}",
"map_title": "Карта нод",
"map_noNodesWithLocation": "Нет нод с данными о местоположении",
"map_nodesNeedGps": "Ноды должны передавать свои GPS-координаты, чтобы отображаться на карте",
"map_nodesCount": "Нод: {count}",
"map_pinsCount": "Меток: {count}",
"map_chat": "Чат",
"map_repeater": "Репитер",
"map_room": "Комната",
"map_sensor": "Сенсор",
"map_pinDm": "Метка (ЛС)",
"map_pinPrivate": "Метка (Приватная)",
"map_pinPublic": "Метка (Публичная)",
"map_lastSeen": "Последнее появление",
"map_disconnectConfirm": "Вы уверены, что хотите отключиться от этого устройства?",
"map_from": "От",
"map_source": "Источник",
"map_flags": "Флаги",
"map_shareMarkerHere": "Поделиться меткой здесь",
"map_pinLabel": "Метка",
"map_label": "Подпись",
"map_pointOfInterest": "Точка интереса",
"map_sendToContact": "Отправить контакту",
"map_sendToChannel": "Отправить в канал",
"map_noChannelsAvailable": "Нет доступных каналов",
"map_publicLocationShare": "Публичная передача местоположения",
"map_publicLocationShareConfirm": "Вы собираетесь поделиться местоположением в {channelLabel}. Этот канал публичный, и любой, у кого есть PSK, сможет его увидеть.",
"map_connectToShareMarkers": "Подключитесь к устройству, чтобы делиться метками",
"map_filterNodes": "Фильтр нод",
"map_nodeTypes": "Типы нод",
"map_chatNodes": "Чат-ноды",
"map_repeaters": "Репитеры",
"map_otherNodes": "Другие ноды",
"map_keyPrefix": "Префикс ключа",
"map_filterByKeyPrefix": "Фильтр по префиксу ключа",
"map_publicKeyPrefix": "Префикс публичного ключа",
"map_markers": "Метки",
"map_showSharedMarkers": "Показывать общие метки",
"map_lastSeenTime": "Время последнего появления",
"map_sharedPin": "Общая метка",
"map_joinRoom": "Присоединиться к комнате",
"map_manageRepeater": "Управление репитером",
"mapCache_title": "Кэш офлайн-карты",
"mapCache_selectAreaFirst": "Сначала выберите область для кэширования",
"mapCache_noTilesToDownload": "Нет плиток для загрузки в этой области",
"mapCache_downloadTilesTitle": "Загрузить плитки",
"mapCache_downloadTilesPrompt": "Загрузить {count} плиток для офлайн-использования?",
"mapCache_downloadAction": "Загрузить",
"mapCache_cachedTiles": "Закэшировано {count} плиток",
"mapCache_cachedTilesWithFailed": "Закэшировано {downloaded} плиток ({failed} не загружено)",
"mapCache_clearOfflineCacheTitle": "Очистить офлайн-кэш",
"mapCache_clearOfflineCachePrompt": "Удалить все закэшированные плитки карты?",
"mapCache_offlineCacheCleared": "Офлайн-кэш очищен",
"mapCache_noAreaSelected": "Область не выбрана",
"mapCache_cacheArea": "Область кэширования",
"mapCache_useCurrentView": "Использовать текущий вид",
"mapCache_zoomRange": "Диапазон масштаба",
"mapCache_estimatedTiles": "Оценочное количество плиток: {count}",
"mapCache_downloadedTiles": "Загружено {completed} из {total}",
"mapCache_downloadTilesButton": "Загрузить плитки",
"mapCache_clearCacheButton": "Очистить кэш",
"mapCache_failedDownloads": "Неудачных загрузок: {count}",
"mapCache_boundsLabel": "С {north}, Ю {south}, В {east}, З {west}",
"time_justNow": "Только что",
"time_minutesAgo": "{minutes} мин назад",
"time_hoursAgo": "{hours} ч назад",
"time_daysAgo": "{days} дн. назад",
"time_hour": "час",
"time_hours": "часов",
"time_day": "день",
"time_days": "дней",
"time_week": "неделя",
"time_weeks": "недель",
"time_month": "месяц",
"time_months": "месяцев",
"time_minutes": "минут",
"time_allTime": "Всё время",
"dialog_disconnect": "Отключиться",
"dialog_disconnectConfirm": "Вы уверены, что хотите отключиться от этого устройства?",
"login_repeaterLogin": "Вход в репитер",
"login_roomLogin": "Вход на сервер комнат",
"login_password": "Пароль",
"login_enterPassword": "Введите пароль",
"login_savePassword": "Сохранить пароль",
"login_savePasswordSubtitle": "Пароль будет надёжно сохранён на этом устройстве",
"login_repeaterDescription": "Введите пароль репитера для доступа к настройкам и статусу.",
"login_roomDescription": "Введите пароль комнаты для доступа к настройкам и статусу.",
"login_routing": "Маршрутизация",
"login_routingMode": "Режим маршрутизации",
"login_autoUseSavedPath": "Авто (использовать сохранённый маршрут)",
"login_forceFloodMode": "Принудительный режим рассылки",
"login_managePaths": "Управление маршрутами",
"login_login": "Войти",
"login_attempt": "Попытка {current}/{max}",
"login_failed": "Ошибка входа: {error}",
"login_failedMessage": "Не удалось войти. Либо пароль неверен, либо репитер недоступен.",
"common_reload": "Обновить",
"common_clear": "Очистить",
"path_currentPath": "Текущий маршрут: {path}",
"path_usingHopsPath": "Используется маршрут из {count} {count, plural, one{хоп} few{хопа} many{хопов} other{хопов}}",
"path_enterCustomPath": "Введите маршрут вручную",
"path_currentPathLabel": "Текущий маршрут",
"path_hexPrefixInstructions": "Введите 2-символьные шестнадцатеричные префиксы для каждого хопа, разделённые запятыми.",
"path_hexPrefixExample": "Пример: A1,F2,3C (каждый узел использует первый байт своего публичного ключа)",
"path_labelHexPrefixes": "Маршрут (шестнадцатеричные префиксы)",
"path_helperMaxHops": "Максимум 64 хопа. Каждый префикс — 2 шестнадцатеричных символа (1 байт)",
"path_selectFromContacts": "Или выберите из контактов:",
"path_noRepeatersFound": "Репитеры или серверы комнат не найдены.",
"path_customPathsRequire": "Пользовательские маршруты требуют промежуточных узлов, способных ретранслировать сообщения.",
"path_invalidHexPrefixes": "Недопустимые шестнадцатеричные префиксы: {prefixes}",
"path_tooLong": "Маршрут слишком длинный. Максимум 64 хопа.",
"path_setPath": "Установить маршрут",
"repeater_management": "Управление репитером",
"room_management": "Управление сервером комнат",
"repeater_managementTools": "Инструменты управления",
"repeater_status": "Статус",
"repeater_statusSubtitle": "Просмотр статуса, статистики и соседей репитера",
"repeater_telemetry": "Телеметрия",
"repeater_telemetrySubtitle": "Просмотр телеметрии датчиков и системной статистики",
"repeater_cli": "CLI",
"repeater_cliSubtitle": "Отправка команд репитеру",
"repeater_neighbours": "Соседи",
"repeater_neighboursSubtitle": "Просмотр соседей на нулевом хопе.",
"repeater_settings": "Настройки",
"repeater_settingsSubtitle": "Настройка параметров репитера",
"repeater_statusTitle": "Статус репитера",
"repeater_routingMode": "Режим маршрутизации",
"repeater_autoUseSavedPath": "Авто (использовать сохранённый маршрут)",
"repeater_forceFloodMode": "Принудительный режим рассылки",
"repeater_pathManagement": "Управление маршрутами",
"repeater_refresh": "Обновить",
"repeater_statusRequestTimeout": "Время ожидания статуса истекло.",
"repeater_errorLoadingStatus": "Ошибка загрузки статуса: {error}",
"repeater_systemInformation": "Системная информация",
"repeater_battery": "Батарея",
"repeater_clockAtLogin": "Время (при входе)",
"repeater_uptime": "Время работы",
"repeater_queueLength": "Длина очереди",
"repeater_debugFlags": "Флаги отладки",
"repeater_radioStatistics": "Радиостатистика",
"repeater_lastRssi": "Последний RSSI",
"repeater_lastSnr": "Последний SNR",
"repeater_noiseFloor": "Уровень шума",
"repeater_txAirtime": "Время эфира (передача)",
"repeater_rxAirtime": "Время эфира (приём)",
"repeater_packetStatistics": "Статистика пакетов",
"repeater_sent": "Отправлено",
"repeater_received": "Получено",
"repeater_duplicates": "Дубликаты",
"repeater_daysHoursMinsSecs": "{days} дн. {hours}ч {minutes}м {seconds}с",
"repeater_packetTxTotal": "Всего: {total}, Рассылка: {flood}, Прямые: {direct}",
"repeater_packetRxTotal": "Всего: {total}, Рассылка: {flood}, Прямые: {direct}",
"repeater_duplicatesFloodDirect": "Рассылка: {flood}, Прямые: {direct}",
"repeater_duplicatesTotal": "Всего: {total}",
"repeater_settingsTitle": "Настройки репитера",
"repeater_basicSettings": "Основные настройки",
"repeater_repeaterName": "Имя репитера",
"repeater_repeaterNameHelper": "Отображаемое имя этого репитера",
"repeater_adminPassword": "Пароль администратора",
"repeater_adminPasswordHelper": "Пароль с полным доступом",
"repeater_guestPassword": "Гостевой пароль",
"repeater_guestPasswordHelper": "Пароль для доступа только для чтения",
"repeater_radioSettings": "Настройки радио",
"repeater_frequencyMhz": "Частота (МГц)",
"repeater_frequencyHelper": "3002500 МГц",
"repeater_txPower": "Мощность передачи",
"repeater_txPowerHelper": "130 дБм",
"repeater_bandwidth": "Полоса пропускания",
"repeater_spreadingFactor": "Коэффициент расширения",
"repeater_codingRate": "Коэффициент кодирования",
"repeater_locationSettings": "Настройки местоположения",
"repeater_latitude": "Широта",
"repeater_latitudeHelper": "В десятичных градусах (напр., 37.7749)",
"repeater_longitude": "Долгота",
"repeater_longitudeHelper": "В десятичных градусах (напр., -122.4194)",
"repeater_features": "Функции",
"repeater_packetForwarding": "Пересылка пакетов",
"repeater_packetForwardingSubtitle": "Разрешить репитеру пересылать пакеты",
"repeater_guestAccess": "Гостевой доступ",
"repeater_guestAccessSubtitle": "Разрешить гостевой доступ только для чтения",
"repeater_privacyMode": "Режим конфиденциальности",
"repeater_privacyModeSubtitle": "Скрывать имя/местоположение в оповещениях",
"repeater_advertisementSettings": "Настройки анонсирования",
"repeater_localAdvertInterval": "Интервал локальных анонсирований",
"repeater_localAdvertIntervalMinutes": "{minutes} минут",
"repeater_floodAdvertInterval": "Интервал анонсирований рассылкой (flood)",
"repeater_floodAdvertIntervalHours": "{hours} часов",
"repeater_encryptedAdvertInterval": "Интервал зашифрованных анонсирований",
"repeater_dangerZone": "Опасная зона",
"repeater_rebootRepeater": "Перезагрузить репитер",
"repeater_rebootRepeaterSubtitle": "Перезапустить устройство репитера",
"repeater_rebootRepeaterConfirm": "Вы уверены, что хотите перезагрузить этот репитер?",
"repeater_regenerateIdentityKey": "Пересоздать ключ идентификации",
"repeater_regenerateIdentityKeySubtitle": "Сгенерировать новую пару публичного/приватного ключей",
"repeater_regenerateIdentityKeyConfirm": "Это создаст новую идентичность для репитера. Продолжить?",
"repeater_eraseFileSystem": "Стереть файловую систему",
"repeater_eraseFileSystemSubtitle": "Отформатировать файловую систему репитера",
"repeater_eraseFileSystemConfirm": "ВНИМАНИЕ: это удалит все данные на репитере. Действие нельзя отменить!",
"repeater_eraseSerialOnly": "Очистка доступна только через последовательную консоль.",
"repeater_commandSent": "Команда отправлена: {command}",
"repeater_errorSendingCommand": "Ошибка отправки команды: {error}",
"repeater_confirm": "Подтвердить",
"repeater_settingsSaved": "Настройки успешно сохранены",
"repeater_errorSavingSettings": "Ошибка сохранения настроек: {error}",
"repeater_refreshBasicSettings": "Обновить основные настройки",
"repeater_refreshRadioSettings": "Обновить настройки радио",
"repeater_refreshTxPower": "Обновить мощность передачи",
"repeater_refreshLocationSettings": "Обновить настройки местоположения",
"repeater_refreshPacketForwarding": "Обновить пересылку пакетов",
"repeater_refreshGuestAccess": "Обновить гостевой доступ",
"repeater_refreshPrivacyMode": "Обновить режим конфиденциальности",
"repeater_refreshAdvertisementSettings": "Обновить настройки анонсирований",
"repeater_refreshed": "{label} обновлён",
"repeater_errorRefreshing": "Ошибка обновления {label}",
"repeater_cliTitle": "CLI репитера",
"repeater_debugNextCommand": "Отладка следующей команды",
"repeater_commandHelp": "Справка по командам",
"repeater_clearHistory": "Очистить историю",
"repeater_noCommandsSent": "Команды ещё не отправлялись",
"repeater_typeCommandOrUseQuick": "Введите команду ниже или используйте быстрые команды",
"repeater_enterCommandHint": "Введите команду...",
"repeater_previousCommand": "Предыдущая команда",
"repeater_nextCommand": "Следующая команда",
"repeater_enterCommandFirst": "Сначала введите команду",
"repeater_cliCommandFrameTitle": "Фрейм CLI-команды",
"repeater_cliCommandError": "Ошибка: {error}",
"repeater_cliQuickGetName": "Получить имя",
"repeater_cliQuickGetRadio": "Получить радио",
"repeater_cliQuickGetTx": "Получить TX",
"repeater_cliQuickNeighbors": "Соседи",
"repeater_cliQuickVersion": "Версия",
"repeater_cliQuickAdvertise": "Анонсировать",
"repeater_cliQuickClock": "Время",
"repeater_cliHelpAdvert": "Отправляет пакет анонсирования",
"repeater_cliHelpReboot": "Перезагружает устройство. (обычно вы получите «Тайм-аут» — это нормально)",
"repeater_cliHelpClock": "Показывает текущее время по часам устройства.",
"repeater_cliHelpPassword": "Устанавливает новый пароль администратора для устройства.",
"repeater_cliHelpVersion": "Показывает версию устройства и дату сборки прошивки.",
"repeater_cliHelpClearStats": "Сбрасывает различные счётчики статистики в ноль.",
"repeater_cliHelpSetAf": "Устанавливает коэффициент времени в эфире.",
"repeater_cliHelpSetTx": "Устанавливает мощность передачи LoRa в дБм. (требуется перезагрузка)",
"repeater_cliHelpSetRepeat": "Включает или отключает роль репитера для этой ноды.",
"repeater_cliHelpSetAllowReadOnly": "(Сервер комнат) Если «on», то вход без пароля разрешён, но публиковать в комнату нельзя (только чтение)",
"repeater_cliHelpSetFloodMax": "Устанавливает максимальное число хопов для входящих пакетов в режиме рассылки (если >= макс., пакет не пересылается)",
"repeater_cliHelpSetIntThresh": "Устанавливает порог интерференции (в дБ). По умолчанию 14. Установите 0, чтобы отключить обнаружение помех.",
"repeater_cliHelpSetAgcResetInterval": "Устанавливает интервал сброса автоматической регулировки усиления. Установите 0, чтобы отключить.",
"repeater_cliHelpSetMultiAcks": "Включает или отключает функцию «двойных ACK».",
"repeater_cliHelpSetAdvertInterval": "Устанавливает интервал (в минутах) отправки локального (нулевой хоп) анонсирования. Установите 0, чтобы отключить.",
"repeater_cliHelpSetFloodAdvertInterval": "Устанавливает интервал (в часах) отправки анонсирований рассылкой. Установите 0, чтобы отключить.",
"repeater_cliHelpSetGuestPassword": "Устанавливает/обновляет гостевой пароль. (для репитеров гости могут отправлять запрос «Get Stats»)",
"repeater_cliHelpSetName": "Устанавливает имя в оповещениях.",
"repeater_cliHelpSetLat": "Устанавливает широту для карты в оповещениях. (десятичные градусы)",
"repeater_cliHelpSetLon": "Устанавливает долготу для карты в оповещениях. (десятичные градусы)",
"repeater_cliHelpSetRadio": "Устанавливает полностью новые параметры радио и сохраняет их в настройки. Требуется команда «reboot» для применения.",
"repeater_cliHelpSetRxDelay": "Устанавливает (экспериментально) базовую задержку (>1 для эффекта) для принятых пакетов на основе качества сигнала. Установите 0, чтобы отключить.",
"repeater_cliHelpSetTxDelay": "Устанавливает множитель времени в эфире для пакета в режиме рассылки и применяет случайную задержку перед пересылкой (чтобы уменьшить коллизии).",
"repeater_cliHelpSetDirectTxDelay": "То же, что txdelay, но для случайной задержки пересылки пакетов в прямом режиме.",
"repeater_cliHelpSetBridgeEnabled": "Включить/выключить мост.",
"repeater_cliHelpSetBridgeDelay": "Установить задержку перед ретрансляцией пакетов.",
"repeater_cliHelpSetBridgeSource": "Выбрать, будет ли мост ретранслировать полученные или отправленные пакеты.",
"repeater_cliHelpSetBridgeBaud": "Установить скорость последовательного соединения для мостов RS232.",
"repeater_cliHelpSetBridgeSecret": "Установить секрет моста для мостов ESP-NOW.",
"repeater_cliHelpSetAdcMultiplier": "Устанавливает пользовательский коэффициент коррекции напряжения батареи (поддерживается только на некоторых платах).",
"repeater_cliHelpTempRadio": "Устанавливает временные параметры радио на заданное число минут, затем возвращает исходные. (НЕ сохраняется в настройки).",
"repeater_cliHelpSetPerm": "Изменяет ACL. Удаляет запись (по префиксу публичного ключа), если «permissions» равен нулю. Добавляет новую запись, если указан полный ключ и он отсутствует в ACL. Обновляет запись по совпадению префикса. Биты прав зависят от роли прошивки, но младшие 2 бита: 0 (Гость), 1 (Только чтение), 2 (Чтение/запись), 3 (Админ)",
"repeater_cliHelpGetBridgeType": "Получает тип моста: none, rs232, espnow",
"repeater_cliHelpLogStart": "Начинает запись пакетов в файловую систему.",
"repeater_cliHelpLogStop": "Останавливает запись пакетов в файловую систему.",
"repeater_cliHelpLogErase": "Удаляет журналы пакетов из файловой системы.",
"repeater_cliHelpNeighbors": "Показывает список других репитеров, услышанных через оповещения нулевого хопа. Каждая строка: префикс-id-в-hex:временная-метка:snr×4",
"repeater_cliHelpNeighborRemove": "Удаляет первую подходящую запись (по префиксу публичного ключа в hex) из списка соседей.",
"repeater_cliHelpRegion": "(только через последовательный порт) Показывает все определённые регионы и текущие права на рассылку.",
"repeater_cliHelpRegionLoad": "ПРИМЕЧАНИЕ: это специальная многострочная команда. Каждая следующая строка — имя региона (с отступом пробелами для указания иерархии, минимум один пробел). Завершается пустой строкой.",
"repeater_cliHelpRegionGet": "Ищет регион по префиксу имени (или «*» для глобальной области). Отвечает: «-> имя-региона (родитель) 'F'»",
"repeater_cliHelpRegionPut": "Добавляет или обновляет определение региона с заданным именем.",
"repeater_cliHelpRegionRemove": "Удаляет определение региона с заданным именем. (должно точно совпадать и не иметь дочерних регионов)",
"repeater_cliHelpRegionAllowf": "Разрешает рассылку («F»lood) для заданного региона. («*» для глобальной/устаревшей области)",
"repeater_cliHelpRegionDenyf": "Запрещает рассылку («F»lood) для заданного региона. (НЕ рекомендуется для глобальной области!)",
"repeater_cliHelpRegionHome": "Показывает текущий «домашний» регион. (Пока не используется, зарезервировано на будущее)",
"repeater_cliHelpRegionHomeSet": "Устанавливает «домашний» регион.",
"repeater_cliHelpRegionSave": "Сохраняет список/карту регионов в память.",
"repeater_cliHelpGps": "Показывает статус GPS. Если GPS выключен — отвечает только «off». Если включён — показывает статус, фиксацию, количество спутников.",
"repeater_cliHelpGpsOnOff": "Переключает состояние питания GPS.",
"repeater_cliHelpGpsSync": "Синхронизирует время ноды с часами GPS.",
"repeater_cliHelpGpsSetLoc": "Устанавливает позицию ноды по координатам GPS и сохраняет в настройки.",
"repeater_cliHelpGpsAdvert": "Показывает конфигурацию передачи местоположения в анонсированиях:\n- none: не включать местоположение\n- share: передавать GPS-координаты (из SensorManager)\n- prefs: передавать координаты из настроек",
"repeater_cliHelpGpsAdvertSet": "Устанавливает конфигурацию передачи местоположения.",
"repeater_commandsListTitle": "Список команд",
"repeater_commandsListNote": "ПРИМЕЧАНИЕ: для большинства команд «set ...» существуют соответствующие команды «get ...».",
"repeater_general": "Общие",
"repeater_settingsCategory": "Настройки",
"repeater_bridge": "Мост",
"repeater_logging": "Журналирование",
"repeater_neighborsRepeaterOnly": "Соседи (только для репитеров)",
"repeater_regionManagementRepeaterOnly": "Управление регионами (только для репитеров)",
"repeater_regionNote": "Команды регионов введены для управления определениями регионов и правами доступа.",
"repeater_gpsManagement": "Управление GPS",
"repeater_gpsNote": "Команда gps введена для управления параметрами, связанными с местоположением.",
"telemetry_receivedData": "Полученные телеметрические данные",
"telemetry_requestTimeout": "Время ожидания телеметрии истекло.",
"telemetry_errorLoading": "Ошибка загрузки телеметрии: {error}",
"telemetry_noData": "Данные телеметрии недоступны.",
"telemetry_channelTitle": "Канал {channel}",
"telemetry_batteryLabel": "Батарея",
"telemetry_voltageLabel": "Напряжение",
"telemetry_mcuTemperatureLabel": "Температура МК",
"telemetry_temperatureLabel": "Температура",
"telemetry_currentLabel": "Ток",
"telemetry_batteryValue": "{percent}% / {volts}В",
"telemetry_voltageValue": "{volts}В",
"telemetry_currentValue": "{amps}А",
"telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F",
"neighbors_receivedData": "Полученные данные о соседях",
"neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.",
"neighbors_errorLoading": "Ошибка загрузки соседей: {error}",
"neighbors_repeatersNeighbours": "Соседи репитеров",
"neighbors_noData": "Данные о соседях недоступны.",
"neighbors_unknownContact": "Неизвестный {pubkey}",
"neighbors_heardA ago": "Слышали: {time} назад",
"channelPath_title": "Путь пакета",
"channelPath_viewMap": "Посмотреть на карте",
"channelPath_otherObservedPaths": "Другие наблюдаемые пути",
"channelPath_repeaterHops": "Хопы через репитеры",
"channelPath_noHopDetails": "Детали хопов для этого пакета не предоставлены.",
"channelPath_messageDetails": "Детали сообщения",
"channelPath_senderLabel": "Отправитель",
"channelPath_timeLabel": "Время",
"channelPath_repeatsLabel": "Повторы",
"channelPath_pathLabel": "Путь {index}",
"channelPath_observedLabel": "Наблюдаемый",
"channelPath_observedPathTitle": "Наблюдаемый путь {index} • {hops}",
"channelPath_noLocationData": "Нет данных о местоположении",
"channelPath_timeWithDate": "{day}/{month} {time}",
"channelPath_timeOnly": "{time}",
"channelPath_unknownPath": "Неизвестный",
"channelPath_floodPath": "Рассылка",
"channelPath_directPath": "Прямой",
"channelPath_observedZeroOf": "0 из {total} хопов",
"channelPath_observedSomeOf": "{observed} из {total} хопов",
"channelPath_mapTitle": "Карта пути",
"channelPath_noRepeaterLocations": "Нет данных о местоположении репитеров для этого пути.",
"channelPath_primaryPath": "Путь {index} (Основной)",
"channelPath_pathLabelTitle": "Путь",
"channelPath_observedPathHeader": "Наблюдаемый путь",
"channelPath_selectedPathLabel": "{label} • {prefixes}",
"channelPath_noHopDetailsAvailable": "Детали хопов для этого пакета недоступны.",
"channelPath_unknownRepeater": "Неизвестный репитер",
"community_title": "Сообщество",
"community_create": "Создать сообщество",
"community_createDesc": "Создать новое сообщество и поделиться через QR-код.",
"community_join": "Присоединиться",
"community_joinTitle": "Присоединиться к сообществу",
"community_joinConfirmation": "Вы хотите присоединиться к сообществу \"{name}\"?",
"community_scanQr": "Сканировать QR-код сообщества",
"community_scanInstructions": "Наведите камеру на QR-код сообщества",
"community_showQr": "Показать QR-код",
"community_publicChannel": "Публичный канал сообщества",
"community_hashtagChannel": "Хэштег-канал сообщества",
"community_name": "Имя сообщества",
"community_enterName": "Введите имя сообщества",
"community_created": "Сообщество \"{name}\" создано",
"community_joined": "Присоединились к сообществу \"{name}\"",
"community_qrTitle": "Поделиться сообществом",
"community_qrInstructions": "Отсканируйте этот QR-код, чтобы присоединиться к \"{name}\"",
"community_hashtagPrivacyHint": "Хэштег-каналы сообщества доступны только его участникам",
"community_invalidQrCode": "Недопустимый QR-код сообщества",
"community_alreadyMember": "Уже участник",
"community_alreadyMemberMessage": "Вы уже участник сообщества \"{name}\".",
"community_addPublicChannel": "Добавить публичный канал сообщества",
"community_addPublicChannelHint": "Автоматически добавить публичный канал для этого сообщества",
"community_noCommunities": "Вы ещё не присоединились ни к одному сообществу",
"community_scanOrCreate": "Отсканируйте QR-код или создайте сообщество, чтобы начать",
"community_manageCommunities": "Управление сообществами",
"community_delete": "Покинуть сообщество",
"community_deleteConfirm": "Покинуть \"{name}\"?",
"community_deleteChannelsWarning": "Это также удалит {count} канал(ов) и их сообщения.",
"community_deleted": "Покинули сообщество \"{name}\"",
"community_regenerateSecret": "Пересоздать секрет",
"community_regenerateSecretConfirm": "Пересоздать секретный ключ для \"{name}\"? Все участники должны будут отсканировать новый QR-код для продолжения общения.",
"community_regenerate": "Пересоздать",
"community_secretRegenerated": "Секрет пересоздан для \"{name}\"",
"community_updateSecret": "Обновить секрет",
"community_secretUpdated": "Секрет обновлён для \"{name}\"",
"community_scanToUpdateSecret": "Отсканируйте новый QR-код, чтобы обновить секрет для \"{name}\"",
"community_addHashtagChannel": "Добавить хэштег-канал сообщества",
"community_addHashtagChannelDesc": "Добавить хэштег-канал для этого сообщества",
"community_selectCommunity": "Выбрать сообщество",
"community_regularHashtag": "Обычный хэштег",
"community_regularHashtagDesc": "Публичный хэштег (любой может присоединиться)",
"community_communityHashtag": "Хэштег сообщества",
"community_communityHashtagDesc": "Доступен только участникам сообщества",
"community_forCommunity": "Для {name}",
"listFilter_tooltip": "Фильтр и сортировка",
"listFilter_sortBy": "Сортировка по",
"listFilter_latestMessages": "Последние сообщения",
"listFilter_heardRecently": "Слышали недавно",
"listFilter_az": "По алфавиту",
"listFilter_filters": "Фильтры",
"listFilter_all": "Все",
"listFilter_users": "Пользователи",
"listFilter_repeaters": "Репитеры",
"listFilter_roomServers": "Серверы комнат",
"listFilter_unreadOnly": "Только непрочитанные",
"listFilter_newGroup": "Новая группа",
"@chat_couldNotOpenLink": {
"placeholders": {
"url": {
"type": "String"
}
}
},
"@neighbors_heardAgo": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"chat_open": "Открыть",
"chat_couldNotOpenLink": "Не удалось открыть ссылку: {url}",
"chat_openLink": "Открыть ссылку?",
"chat_openLinkConfirmation": "Хотите открыть эту ссылку в вашем браузере?",
"neighbors_heardAgo": "Слушал(а): {time} назад",
"chat_invalidLink": "Неправильный формат ссылки"
}

View file

@ -8,6 +8,7 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart';
@ -17,6 +18,7 @@ import '../models/channel_message.dart';
import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
@ -35,42 +37,51 @@ class ChannelChatScreen extends StatefulWidget {
class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage;
final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false;
@override
void initState() {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
// Scroll to bottom when opening channel chat - use SchedulerBinding for next frame
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen();
}
}
Future<void> _loadOlderMessages() async {
if (_isLoadingOlder) return;
setState(() => _isLoadingOlder = true);
final connector = context.read<MeshCoreConnector>();
await connector.loadOlderChannelMessages(widget.channel.index);
if (mounted) {
setState(() => _isLoadingOlder = false);
}
}
@override
void dispose() {
context.read<MeshCoreConnector>().setActiveChannel(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
void _setReplyingTo(ChannelMessage message) {
setState(() {
_replyingToMessage = message;
@ -155,10 +166,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel);
SchedulerBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
if (messages.isEmpty) {
return Center(
child: Column(
@ -192,20 +199,51 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
return Container(
key: _messageKeys[message.messageId]!,
child: _buildMessageBubble(message),
);
},
// Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList();
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.scrollToBottomIfAtBottom();
});
return Stack(
children: [
ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.all(8),
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final messageIndex = index;
final message = reversedMessages[messageIndex];
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
return Container(
key: _messageKeys[message.messageId]!,
child: _buildMessageBubble(message),
);
},
),
JumpToBottomButton(
scrollController: _scrollController,
),
],
);
},
),
@ -243,7 +281,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
@ -257,15 +297,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 4),
if (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
@ -274,12 +319,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor: isOutgoing
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
)
else
Linkify(
@ -299,46 +347,56 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
),
],
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
const SizedBox(width: 2),
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${message.repeatCount}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status == ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color: message.status == ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status == ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color: message.status == ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
),
],
),
@ -377,8 +435,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor: previewTextColor,
width: 120,
height: 80,
maxSize: 80,
),
);
} else if (poi != null) {
@ -703,14 +760,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Row(
children: [
Expanded(
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
width: 160,
height: 110,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
@ -724,6 +783,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],

View file

@ -312,6 +312,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context),
tooltip: context.l10n.channels_addChannel,
child: const Icon(Icons.add),
),
bottomNavigationBar: SafeArea(

View file

@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
@ -22,6 +23,7 @@ import 'map_screen.dart';
import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart';
import '../widgets/path_selection_dialog.dart';
import '../utils/app_logger.dart';
@ -38,25 +40,44 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> {
final _textController = TextEditingController();
final _scrollController = ScrollController();
final _scrollController = ChatScrollController();
final _textFieldFocusNode = FocusNode();
bool _isLoadingOlder = false;
@override
void initState() {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
// Scroll to bottom when opening chat use SchedulerBinding for next frame
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen();
}
}
Future<void> _loadOlderMessages() async {
if (_isLoadingOlder) return;
setState(() => _isLoadingOlder = true);
final connector = context.read<MeshCoreConnector>();
await connector.loadOlderMessages(widget.contact.publicKeyHex);
if (mounted) {
setState(() => _isLoadingOlder = false);
}
}
@override
void dispose() {
context.read<MeshCoreConnector>().setActiveContact(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
_scrollController.dispose();
super.dispose();
@ -169,9 +190,16 @@ class _ChatScreenState extends State<ChatScreen> {
return Column(
children: [
Expanded(
child: messages.isEmpty
? _buildEmptyState()
: _buildMessageList(messages, connector),
child: Stack(
children: [
messages.isEmpty
? _buildEmptyState()
: _buildMessageList(messages, connector),
JumpToBottomButton(
scrollController: _scrollController,
),
],
),
),
_buildInputBar(connector),
],
@ -203,13 +231,37 @@ class _ChatScreenState extends State<ChatScreen> {
}
Widget _buildMessageList(List<Message> messages, MeshCoreConnector connector) {
// Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList();
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.scrollToBottomIfAtBottom();
});
return ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
itemCount: messages.length,
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final messageIndex = index;
Contact contact = widget.contact;
final message = messages[index];
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
@ -258,13 +310,15 @@ class _ChatScreenState extends State<ChatScreen> {
return Row(
children: [
Expanded(
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor:
colorScheme.onSurface.withValues(alpha: 0.6),
width: 160,
height: 110,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor:
colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
@ -278,6 +332,7 @@ class _ChatScreenState extends State<ChatScreen> {
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
@ -339,16 +394,6 @@ class _ChatScreenState extends State<ChatScreen> {
text,
);
_textController.clear();
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@ -960,7 +1005,9 @@ class _MessageBubble extends StatelessWidget {
],
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
@ -972,23 +1019,31 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Text(
senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
: EdgeInsets.zero,
child: Text(
senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
const SizedBox(height: 4),
if (gifId == null) const SizedBox(height: 4),
],
if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null)
GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: bubbleColor,
fallbackTextColor: textColor.withValues(alpha: 0.7),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: textColor.withValues(alpha: 0.7),
),
)
else
Linkify(
@ -1009,48 +1064,58 @@ class _MessageBubble extends StatelessWidget {
),
if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4),
Text(
context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 10,
color: metaColor,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
_buildStatusIcon(metaColor),
],
if (message.tripTimeMs != null &&
message.status == MessageStatus.delivered) ...[
const SizedBox(width: 4),
Icon(
Icons.speed,
size: 10,
color: isOutgoing ? metaColor : Colors.green[700],
),
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
: EdgeInsets.zero,
child: Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 9,
color: isOutgoing ? metaColor : Colors.green[700],
fontSize: 10,
color: metaColor,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
_buildStatusIcon(metaColor),
],
if (message.tripTimeMs != null &&
message.status == MessageStatus.delivered) ...[
const SizedBox(width: 4),
Icon(
Icons.speed,
size: 10,
color: isOutgoing ? metaColor : Colors.green[700],
),
Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
style: TextStyle(
fontSize: 9,
color: isOutgoing ? metaColor : Colors.green[700],
),
),
],
],
],
),
),
],
),

View file

@ -313,6 +313,14 @@ class _ContactsScreenState extends State<ContactsScreen>
return matchesContactQuery(contact, _searchQuery);
}).toList();
// Filter out own node from the list
if (connector.selfPublicKey != null) {
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
filtered = filtered.where((contact) {
return contact.publicKeyHex != selfPubKeyHex;
}).toList();
}
if (_typeFilter != ContactTypeFilter.all) {
filtered = filtered.where(_matchesTypeFilter).toList();
}
@ -863,21 +871,30 @@ class _ContactTile extends StatelessWidget {
subtitle: Text(
'${contact.typeLabel}${contact.pathLabel} $shotPublicKey',
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
),
onTap: onTap,
onLongPress: onLongPress,

View file

@ -354,6 +354,7 @@ class _MapScreenState extends State<MapScreen> {
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showFilterDialog(context, settingsService),
tooltip: context.l10n.map_filterNodes,
child: const Icon(Icons.filter_list),
),
),

View file

@ -6,16 +6,14 @@ class GifMessage extends StatefulWidget {
final String url;
final Color backgroundColor;
final Color fallbackTextColor;
final double width;
final double height;
final double maxSize;
const GifMessage({
super.key,
required this.url,
required this.backgroundColor,
required this.fallbackTextColor,
this.width = 200,
this.height = 140,
this.maxSize = 200,
});
@override
@ -122,6 +120,28 @@ class _GifMessageState extends State<GifMessage> {
@override
Widget build(BuildContext context) {
// Calculate display size based on image aspect ratio
// Use 4:3 placeholder aspect ratio during loading to minimize layout shifts
double displayWidth = widget.maxSize;
double displayHeight = widget.maxSize * 0.75;
if (_image != null) {
final imageWidth = _image!.width.toDouble();
final imageHeight = _image!.height.toDouble();
final aspectRatio = imageWidth / imageHeight;
// Fit within maxSize, calculating dimensions from aspect ratio
if (aspectRatio >= 1) {
// Wider than tall: constrain by width
displayWidth = widget.maxSize;
displayHeight = displayWidth / aspectRatio;
} else {
// Taller than wide: constrain by height
displayHeight = widget.maxSize;
displayWidth = displayHeight * aspectRatio;
}
}
Widget content;
if (_error != null) {
@ -151,33 +171,30 @@ class _GifMessageState extends State<GifMessage> {
} else {
content = RawImage(
image: _image,
fit: BoxFit.cover,
width: widget.width,
height: widget.height,
fit: BoxFit.contain,
width: displayWidth,
height: displayHeight,
);
}
return GestureDetector(
onTap: _togglePause,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
color: widget.backgroundColor,
width: widget.width,
height: widget.height,
child: Stack(
fit: StackFit.expand,
children: [
content,
if (_isPaused && _image != null)
Container(
color: Colors.black.withValues(alpha: 0.2),
child: const Center(
child: Icon(Icons.pause, color: Colors.white70, size: 28),
),
child: Container(
color: widget.backgroundColor,
width: displayWidth,
height: displayHeight,
child: Stack(
fit: StackFit.expand,
children: [
content,
if (_isPaused && _image != null)
Container(
color: Colors.black.withValues(alpha: 0.2),
child: const Center(
child: Icon(Icons.pause, color: Colors.white70, size: 28),
),
],
),
),
],
),
),
);

View file

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../helpers/chat_scroll_controller.dart';
class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController;
const JumpToBottomButton({
super.key,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: scrollController.showJumpToBottom,
builder: (context, show, _) {
if (!show) return const SizedBox.shrink();
return Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.small(
onPressed: scrollController.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
);
},
);
}
}