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