mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
13 Commits
v2026.0307
...
v2026.0307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c8664d04 | ||
|
|
162376cc47 | ||
|
|
adb8fe32f4 | ||
|
|
5966ddfbc1 | ||
|
|
1867f815d2 | ||
|
|
4010680da0 | ||
|
|
6bfc08f3ae | ||
|
|
fa37a20fc7 | ||
|
|
09f61740d2 | ||
|
|
97c1f3b27d | ||
|
|
cf70aa2bfb | ||
|
|
fd7d6afb70 | ||
|
|
8a4b29b968 |
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 97;
|
CURRENT_PROJECT_VERSION = 100;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 97;
|
CURRENT_PROJECT_VERSION = 100;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 97;
|
CURRENT_PROJECT_VERSION = 100;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 97;
|
CURRENT_PROJECT_VERSION = 100;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 97;
|
CURRENT_PROJECT_VERSION = 100;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 97;
|
CURRENT_PROJECT_VERSION = 100;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>97</string>
|
<string>100</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
|||||||
@@ -5,22 +5,17 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.43503">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.436347">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="107.968551">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="49.579697">
|
||||||
|
|
||||||
</testcase>
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="271.15329">
|
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'app_localizations_es.dart';
|
|||||||
import 'app_localizations_fr.dart';
|
import 'app_localizations_fr.dart';
|
||||||
import 'app_localizations_hr.dart';
|
import 'app_localizations_hr.dart';
|
||||||
import 'app_localizations_it.dart';
|
import 'app_localizations_it.dart';
|
||||||
|
import 'app_localizations_ru.dart';
|
||||||
import 'app_localizations_sl.dart';
|
import 'app_localizations_sl.dart';
|
||||||
import 'app_localizations_zh.dart';
|
import 'app_localizations_zh.dart';
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ abstract class AppLocalizations {
|
|||||||
Locale('fr'),
|
Locale('fr'),
|
||||||
Locale('hr'),
|
Locale('hr'),
|
||||||
Locale('it'),
|
Locale('it'),
|
||||||
|
Locale('ru'),
|
||||||
Locale('sl'),
|
Locale('sl'),
|
||||||
Locale('zh'),
|
Locale('zh'),
|
||||||
];
|
];
|
||||||
@@ -4226,6 +4228,7 @@ class _AppLocalizationsDelegate
|
|||||||
'fr',
|
'fr',
|
||||||
'hr',
|
'hr',
|
||||||
'it',
|
'it',
|
||||||
|
'ru',
|
||||||
'sl',
|
'sl',
|
||||||
'zh',
|
'zh',
|
||||||
].contains(locale.languageCode);
|
].contains(locale.languageCode);
|
||||||
@@ -4251,6 +4254,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
|||||||
return AppLocalizationsHr();
|
return AppLocalizationsHr();
|
||||||
case 'it':
|
case 'it':
|
||||||
return AppLocalizationsIt();
|
return AppLocalizationsIt();
|
||||||
|
case 'ru':
|
||||||
|
return AppLocalizationsRu();
|
||||||
case 'sl':
|
case 'sl':
|
||||||
return AppLocalizationsSl();
|
return AppLocalizationsSl();
|
||||||
case 'zh':
|
case 'zh':
|
||||||
|
|||||||
2348
lib/l10n/app_localizations_ru.dart
Normal file
2348
lib/l10n/app_localizations_ru.dart
Normal file
File diff suppressed because it is too large
Load Diff
685
lib/l10n/app_ru.arb
Normal file
685
lib/l10n/app_ru.arb
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
{
|
||||||
|
"@@locale": "ru",
|
||||||
|
|
||||||
|
"appTitle": "MeshCore SAR",
|
||||||
|
"messages": "Сообщения",
|
||||||
|
"contacts": "Контакты",
|
||||||
|
"map": "Карта",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"connect": "Подключить",
|
||||||
|
"disconnect": "Отключить",
|
||||||
|
"scanningForDevices": "Поиск устройств...",
|
||||||
|
"noDevicesFound": "Устройства не найдены",
|
||||||
|
"scanAgain": "Повторить поиск",
|
||||||
|
"tapToConnect": "Нажмите для подключения",
|
||||||
|
"deviceNotConnected": "Устройство не подключено",
|
||||||
|
"locationPermissionDenied": "Доступ к геолокации запрещён",
|
||||||
|
"locationPermissionPermanentlyDenied": "Доступ к геолокации запрещён навсегда. Включите его в настройках.",
|
||||||
|
"locationPermissionRequired": "Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.",
|
||||||
|
"locationServicesDisabled": "Службы геолокации отключены. Пожалуйста, включите их в настройках.",
|
||||||
|
"failedToGetGpsLocation": "Не удалось получить GPS-координаты",
|
||||||
|
"advertisedAtLocation": "Транслируется в {latitude}, {longitude}",
|
||||||
|
"failedToAdvertise": "Ошибка трансляции: {error}",
|
||||||
|
"reconnecting": "Переподключение... ({attempt}/{max})",
|
||||||
|
"cancelReconnection": "Отменить переподключение",
|
||||||
|
"mapManagement": "Управление картой",
|
||||||
|
"general": "Основные",
|
||||||
|
"theme": "Тема",
|
||||||
|
"chooseTheme": "Выбрать тему",
|
||||||
|
"light": "Светлая",
|
||||||
|
"dark": "Тёмная",
|
||||||
|
"blueLightTheme": "Синяя светлая тема",
|
||||||
|
"blueDarkTheme": "Синяя тёмная тема",
|
||||||
|
"sarRed": "SAR Красная",
|
||||||
|
"alertEmergencyMode": "Режим тревоги / ЧС",
|
||||||
|
"sarGreen": "SAR Зелёная",
|
||||||
|
"safeAllClearMode": "Режим «Всё в порядке»",
|
||||||
|
"autoSystem": "Авто (Система)",
|
||||||
|
"followSystemTheme": "Следовать системной теме",
|
||||||
|
"showRxTxIndicators": "Показывать индикаторы RX/TX",
|
||||||
|
"displayPacketActivity": "Отображать активность пакетов в верхней панели",
|
||||||
|
"simpleMode": "Простой режим",
|
||||||
|
"simpleModeDescription": "Скрыть второстепенную информацию в сообщениях и контактах",
|
||||||
|
"disableMap": "Отключить карту",
|
||||||
|
"disableMapDescription": "Скрыть вкладку карты для экономии заряда батареи",
|
||||||
|
"language": "Язык",
|
||||||
|
"chooseLanguage": "Выбрать язык",
|
||||||
|
"english": "Английский",
|
||||||
|
"slovenian": "Словенский",
|
||||||
|
"croatian": "Хорватский",
|
||||||
|
"german": "Немецкий",
|
||||||
|
"spanish": "Испанский",
|
||||||
|
"french": "Французский",
|
||||||
|
"italian": "Итальянский",
|
||||||
|
"russian": "Русский",
|
||||||
|
"locationBroadcasting": "Трансляция местоположения",
|
||||||
|
"autoLocationTracking": "Авто-отслеживание геолокации",
|
||||||
|
"automaticallyBroadcastPosition": "Автоматически транслировать обновления позиции",
|
||||||
|
"configureTracking": "Настроить отслеживание",
|
||||||
|
"distanceAndTimeThresholds": "Пороги расстояния и времени",
|
||||||
|
"locationTrackingConfiguration": "Настройка отслеживания геолокации",
|
||||||
|
"configureWhenLocationBroadcasts": "Настройте условия отправки обновлений геолокации в mesh-сеть",
|
||||||
|
"minimumDistance": "Минимальное расстояние",
|
||||||
|
"broadcastAfterMoving": "Транслировать только после перемещения на {distance} м",
|
||||||
|
"maximumDistance": "Максимальное расстояние",
|
||||||
|
"alwaysBroadcastAfterMoving": "Всегда транслировать после перемещения на {distance} м",
|
||||||
|
"minimumTimeInterval": "Минимальный интервал времени",
|
||||||
|
"alwaysBroadcastEvery": "Всегда транслировать каждые {duration}",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"about": "О приложении",
|
||||||
|
"appVersion": "Версия приложения",
|
||||||
|
"appName": "Название приложения",
|
||||||
|
"aboutMeshCoreSar": "О MeshCore SAR",
|
||||||
|
"aboutDescription": "Приложение для поисково-спасательных операций, разработанное для аварийно-спасательных служб. Возможности:\n\n• BLE mesh-сеть для связи между устройствами\n• Офлайн-карты с несколькими вариантами слоёв\n• Отслеживание членов команды в реальном времени\n• Тактические маркеры SAR (найденный человек, пожар, место сбора)\n• Управление контактами и обмен сообщениями\n• GPS-трекинг с показанием курса компаса\n• Кэширование тайлов карт для работы офлайн",
|
||||||
|
"technologiesUsed": "Используемые технологии:",
|
||||||
|
"technologiesList": "• Flutter для кросс-платформенной разработки\n• BLE (Bluetooth Low Energy) для mesh-сети\n• OpenStreetMap для карт\n• Provider для управления состоянием\n• SharedPreferences для локального хранилища",
|
||||||
|
"moreInfo": "Подробнее",
|
||||||
|
"learnMoreAbout": "Узнать больше о MeshCore SAR",
|
||||||
|
"developer": "Разработчик",
|
||||||
|
"packageName": "Имя пакета",
|
||||||
|
"sampleData": "Тестовые данные",
|
||||||
|
"sampleDataDescription": "Загрузить или очистить тестовые контакты, сообщения каналов и маркеры SAR",
|
||||||
|
"loadSampleData": "Загрузить тестовые данные",
|
||||||
|
"clearAllData": "Очистить все данные",
|
||||||
|
"clearAllDataConfirmTitle": "Очистить все данные",
|
||||||
|
"clearAllDataConfirmMessage": "Это удалит все контакты и маркеры SAR. Вы уверены?",
|
||||||
|
"clear": "Очистить",
|
||||||
|
"loadedSampleData": "Загружено: {teamCount} членов команды, {channelCount} каналов, {sarCount} маркеров SAR, {messageCount} сообщений",
|
||||||
|
"failedToLoadSampleData": "Не удалось загрузить тестовые данные: {error}",
|
||||||
|
"allDataCleared": "Все данные очищены",
|
||||||
|
"failedToStartBackgroundTracking": "Не удалось запустить фоновое отслеживание. Проверьте разрешения и BLE-соединение.",
|
||||||
|
"locationBroadcast": "Трансляция геолокации: {latitude}, {longitude}",
|
||||||
|
"defaultPinInfo": "PIN-код по умолчанию для устройств без экрана — 123456. Проблемы с сопряжением? Удалите устройство из Bluetooth в системных настройках.",
|
||||||
|
"noMessagesYet": "Сообщений пока нет",
|
||||||
|
"pullDownToSync": "Потяните вниз для синхронизации сообщений",
|
||||||
|
"deleteContact": "Удалить контакт",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"viewOnMap": "Показать на карте",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"sendDirectMessage": "Отправить",
|
||||||
|
"resetPath": "Сбросить маршрут (перепроложить)",
|
||||||
|
"publicKeyCopied": "Публичный ключ скопирован в буфер обмена",
|
||||||
|
"copiedToClipboard": "{label} скопировано в буфер обмена",
|
||||||
|
"pleaseEnterPassword": "Пожалуйста, введите пароль",
|
||||||
|
"failedToSyncContacts": "Не удалось синхронизировать контакты: {error}",
|
||||||
|
"loggedInSuccessfully": "Вход выполнен! Ожидание сообщений комнаты...",
|
||||||
|
"loginFailed": "Ошибка входа — неверный пароль",
|
||||||
|
"loggingIn": "Вход в {roomName}...",
|
||||||
|
"failedToSendLogin": "Не удалось отправить данные входа: {error}",
|
||||||
|
"lowLocationAccuracy": "Низкая точность геолокации",
|
||||||
|
"continue_": "Продолжить",
|
||||||
|
"sendSarMarker": "Отправить маркер SAR",
|
||||||
|
"deleteDrawing": "Удалить рисунок",
|
||||||
|
"drawingTools": "Инструменты рисования",
|
||||||
|
"drawLine": "Нарисовать линию",
|
||||||
|
"drawLineDesc": "Нарисуйте произвольную линию на карте",
|
||||||
|
"drawRectangle": "Нарисовать прямоугольник",
|
||||||
|
"drawRectangleDesc": "Нарисуйте прямоугольную область на карте",
|
||||||
|
"measureDistance": "Измерить расстояние",
|
||||||
|
"measureDistanceDesc": "Долгое нажатие на две точки для измерения",
|
||||||
|
"clearMeasurement": "Сбросить измерение",
|
||||||
|
"distanceLabel": "Расстояние: {distance}",
|
||||||
|
"longPressForSecondPoint": "Долгое нажатие для второй точки",
|
||||||
|
"longPressToStartMeasurement": "Долгое нажатие для установки первой точки",
|
||||||
|
"longPressToStartNewMeasurement": "Долгое нажатие для начала нового измерения",
|
||||||
|
"shareDrawings": "Поделиться рисунками",
|
||||||
|
"clearAllDrawings": "Удалить все рисунки",
|
||||||
|
"completeLine": "Завершить линию",
|
||||||
|
"broadcastDrawingsToTeam": "Транслировать {count} рисун{plural} команде",
|
||||||
|
"removeAllDrawings": "Удалить все {count} рисун{plural}",
|
||||||
|
"deleteAllDrawingsConfirm": "Удалить все {count} рисун{plural} с карты?",
|
||||||
|
"drawing": "Рисунок",
|
||||||
|
"shareDrawingsCount": "Поделиться {count} рисун{plural}",
|
||||||
|
"sentDrawingsToRoom": "Отправлено {count} рисун{plural} в {roomName}",
|
||||||
|
"sharedDrawingsToRoom": "Передано {success}/{total} рисун{plural} в {roomName}",
|
||||||
|
"showReceivedDrawings": "Показать полученные рисунки",
|
||||||
|
"showingAllDrawings": "Показаны все рисунки",
|
||||||
|
"showingOnlyYourDrawings": "Показаны только ваши рисунки",
|
||||||
|
"showSarMarkers": "Показать маркеры SAR",
|
||||||
|
"showingSarMarkers": "Маркеры SAR отображаются",
|
||||||
|
"hidingSarMarkers": "Маркеры SAR скрыты",
|
||||||
|
"clearAll": "Очистить всё",
|
||||||
|
"noLocalDrawings": "Нет локальных рисунков для отправки",
|
||||||
|
"publicChannel": "Публичный канал",
|
||||||
|
"broadcastToAll": "Трансляция всем ближайшим узлам (временно)",
|
||||||
|
"storedPermanently": "Сохранено постоянно в комнате",
|
||||||
|
"drawingsSentToPublicChannel": "Отправлено {count} рисун{plural} в публичный канал",
|
||||||
|
"drawingsSharedToPublicChannel": "Передано {success}/{total} рисунков в публичный канал",
|
||||||
|
"notConnectedToDevice": "Устройство не подключено",
|
||||||
|
"directMessage": "Личное сообщение",
|
||||||
|
"directMessageSentTo": "Личное сообщение отправлено {contactName}",
|
||||||
|
"failedToSend": "Не удалось отправить: {error}",
|
||||||
|
"directMessageInfo": "Это сообщение будет отправлено напрямую {contactName}. Оно также появится в общей ленте сообщений.",
|
||||||
|
"typeYourMessage": "Введите сообщение...",
|
||||||
|
"quickLocationMarker": "Быстрый маркер местоположения",
|
||||||
|
"markerType": "Тип маркера",
|
||||||
|
"sendTo": "Отправить в",
|
||||||
|
"noDestinationsAvailable": "Нет доступных получателей.",
|
||||||
|
"selectDestination": "Выберите получателя...",
|
||||||
|
"ephemeralBroadcastInfo": "Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.",
|
||||||
|
"persistentRoomInfo": "Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.",
|
||||||
|
"location": "Местоположение",
|
||||||
|
"myLocation": "Моё местоположение",
|
||||||
|
"fromMap": "С карты",
|
||||||
|
"gettingLocation": "Получение местоположения...",
|
||||||
|
"locationError": "Ошибка геолокации",
|
||||||
|
"retry": "Повторить",
|
||||||
|
"refreshLocation": "Обновить местоположение",
|
||||||
|
"accuracyMeters": "Точность: ±{accuracy}м",
|
||||||
|
"notesOptional": "Заметки (необязательно)",
|
||||||
|
"addAdditionalInformation": "Добавьте дополнительную информацию...",
|
||||||
|
"lowAccuracyWarning": "Точность геолокации: ±{accuracy}м. Этого может быть недостаточно для операций SAR.\n\nПродолжить всё равно?",
|
||||||
|
"loginToRoom": "Войти в комнату",
|
||||||
|
"enterPasswordInfo": "Введите пароль для доступа к этой комнате. Пароль будет сохранён для дальнейшего использования.",
|
||||||
|
"password": "Пароль",
|
||||||
|
"enterRoomPassword": "Введите пароль комнаты",
|
||||||
|
"loggingInDots": "Вход...",
|
||||||
|
"login": "Войти",
|
||||||
|
"failedToAddRoom": "Не удалось добавить комнату на устройство: {error}\n\nВозможно, комната ещё не объявила себя.\nПопробуйте подождать, пока комната не выйдет на связь.",
|
||||||
|
"direct": "Напрямую",
|
||||||
|
"flood": "Широковещательно",
|
||||||
|
"admin": "Администратор",
|
||||||
|
"loggedIn": "Вход выполнен",
|
||||||
|
"noGpsData": "Нет данных GPS",
|
||||||
|
"distance": "Расстояние",
|
||||||
|
"pingingDirect": "Пинг {name} (напрямую по маршруту)...",
|
||||||
|
"pingingFlood": "Пинг {name} (широковещательно — нет маршрута)...",
|
||||||
|
"directPingTimeout": "Таймаут прямого пинга — повтор {name} широковещательно...",
|
||||||
|
"pingSuccessful": "Пинг успешен: {name}{fallback}",
|
||||||
|
"viaFloodingFallback": " (через широковещательный резерв)",
|
||||||
|
"pingFailed": "Пинг не удался: {name} — ответ не получен",
|
||||||
|
"deleteContactConfirmation": "Вы уверены, что хотите удалить \"{name}\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.",
|
||||||
|
"removingContact": "Удаление {name}...",
|
||||||
|
"contactRemoved": "Контакт \"{name}\" удалён",
|
||||||
|
"failedToRemoveContact": "Не удалось удалить контакт: {error}",
|
||||||
|
"type": "Тип",
|
||||||
|
"publicKey": "Публичный ключ",
|
||||||
|
"lastSeen": "Последнее появление",
|
||||||
|
"roomStatus": "Статус комнаты",
|
||||||
|
"loginStatus": "Статус входа",
|
||||||
|
"notLoggedIn": "Не выполнен вход",
|
||||||
|
"adminAccess": "Доступ администратора",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Нет",
|
||||||
|
"permissions": "Разрешения",
|
||||||
|
"passwordSaved": "Пароль сохранён",
|
||||||
|
"locationColon": "Местоположение:",
|
||||||
|
"telemetry": "Телеметрия",
|
||||||
|
"requestingTelemetry": "Запрос телеметрии от {name}...",
|
||||||
|
"voltage": "Напряжение",
|
||||||
|
"battery": "Батарея",
|
||||||
|
"temperature": "Температура",
|
||||||
|
"humidity": "Влажность",
|
||||||
|
"pressure": "Давление",
|
||||||
|
"gpsTelemetry": "GPS (телеметрия)",
|
||||||
|
"updated": "Обновлено",
|
||||||
|
"pathResetInfo": "Маршрут сброшен для {name}. Следующее сообщение найдёт новый путь.",
|
||||||
|
"reLoginToRoom": "Войти в комнату повторно",
|
||||||
|
"heading": "Курс",
|
||||||
|
"elevation": "Высота",
|
||||||
|
"accuracy": "Точность",
|
||||||
|
"bearing": "Азимут",
|
||||||
|
"direction": "Направление",
|
||||||
|
"filterMarkers": "Фильтр маркеров",
|
||||||
|
"filterMarkersTooltip": "Фильтровать маркеры",
|
||||||
|
"contactsFilter": "Контакты",
|
||||||
|
"repeatersFilter": "Ретрансляторы",
|
||||||
|
"sarMarkers": "Маркеры SAR",
|
||||||
|
"foundPerson": "Найденный человек",
|
||||||
|
"fire": "Пожар",
|
||||||
|
"stagingArea": "Место сбора",
|
||||||
|
"showAll": "Показать все",
|
||||||
|
"nearbyContacts": "Ближайшие контакты",
|
||||||
|
"locationUnavailable": "Местоположение недоступно",
|
||||||
|
"ahead": "впереди",
|
||||||
|
"degreesRight": "{degrees}° вправо",
|
||||||
|
"degreesLeft": "{degrees}° влево",
|
||||||
|
"latLonFormat": "Ш: {latitude} Д: {longitude}",
|
||||||
|
"noContactsYet": "Контактов пока нет",
|
||||||
|
"connectToDeviceToLoadContacts": "Подключите устройство для загрузки контактов",
|
||||||
|
"teamMembers": "Члены команды",
|
||||||
|
"repeaters": "Ретрансляторы",
|
||||||
|
"rooms": "Комнаты",
|
||||||
|
"channels": "Каналы",
|
||||||
|
"cacheStatistics": "Статистика кэша",
|
||||||
|
"totalTiles": "Всего тайлов",
|
||||||
|
"cacheSize": "Размер кэша",
|
||||||
|
"storeName": "Имя хранилища",
|
||||||
|
"noCacheStatistics": "Статистика кэша недоступна",
|
||||||
|
"downloadRegion": "Скачать регион",
|
||||||
|
"mapLayer": "Слой карты",
|
||||||
|
"regionBounds": "Границы региона",
|
||||||
|
"north": "Север",
|
||||||
|
"south": "Юг",
|
||||||
|
"east": "Восток",
|
||||||
|
"west": "Запад",
|
||||||
|
"zoomLevels": "Уровни масштаба",
|
||||||
|
"minZoom": "Мин: {zoom}",
|
||||||
|
"maxZoom": "Макс: {zoom}",
|
||||||
|
"downloadingDots": "Загрузка...",
|
||||||
|
"cancelDownload": "Отменить загрузку",
|
||||||
|
"downloadRegionButton": "Скачать регион",
|
||||||
|
"downloadNote": "Внимание: большие регионы или высокие уровни масштаба могут потребовать значительного времени и места.",
|
||||||
|
"cacheManagement": "Управление кэшем",
|
||||||
|
"clearAllMaps": "Очистить все карты",
|
||||||
|
"clearMapsConfirmTitle": "Очистить все карты",
|
||||||
|
"clearMapsConfirmMessage": "Вы уверены, что хотите удалить все загруженные карты? Это действие нельзя отменить.",
|
||||||
|
"mapDownloadCompleted": "Загрузка карты завершена!",
|
||||||
|
"cacheClearedSuccessfully": "Кэш успешно очищен!",
|
||||||
|
"downloadCancelled": "Загрузка отменена",
|
||||||
|
"startingDownload": "Начало загрузки...",
|
||||||
|
"downloadingMapTiles": "Загрузка тайлов карты...",
|
||||||
|
"downloadCompletedSuccessfully": "Загрузка успешно завершена!",
|
||||||
|
"cancellingDownload": "Отмена загрузки...",
|
||||||
|
"errorLoadingStats": "Ошибка загрузки статистики: {error}",
|
||||||
|
"downloadFailed": "Ошибка загрузки: {error}",
|
||||||
|
"cancelFailed": "Ошибка отмены: {error}",
|
||||||
|
"clearCacheFailed": "Ошибка очистки кэша: {error}",
|
||||||
|
"minZoomError": "Мин. масштаб: {error}",
|
||||||
|
"maxZoomError": "Макс. масштаб: {error}",
|
||||||
|
"minZoomGreaterThanMax": "Минимальный масштаб должен быть меньше или равен максимальному",
|
||||||
|
"selectMapLayer": "Выбрать слой карты",
|
||||||
|
"mapOptions": "Параметры карты",
|
||||||
|
"showLegend": "Показать легенду",
|
||||||
|
"displayMarkerTypeCounts": "Отображать счётчики типов маркеров",
|
||||||
|
"rotateMapWithHeading": "Поворачивать карту по курсу",
|
||||||
|
"mapFollowsDirection": "Карта следует вашему направлению движения",
|
||||||
|
"resetMapRotation": "Сбросить поворот",
|
||||||
|
"resetMapRotationTooltip": "Вернуть карту на север",
|
||||||
|
"showMapDebugInfo": "Показать отладочную информацию карты",
|
||||||
|
"displayZoomLevelBounds": "Отображать уровень масштаба и границы",
|
||||||
|
"fullscreenMode": "Полноэкранный режим",
|
||||||
|
"hideUiFullMapView": "Скрыть все элементы интерфейса для полного вида карты",
|
||||||
|
"openStreetMap": "OpenStreetMap",
|
||||||
|
"openTopoMap": "OpenTopoMap",
|
||||||
|
"esriSatellite": "ESRI Спутник",
|
||||||
|
"googleHybrid": "Google Гибрид",
|
||||||
|
"googleRoadmap": "Google Дороги",
|
||||||
|
"googleTerrain": "Google Рельеф",
|
||||||
|
"downloadVisibleArea": "Скачать видимую область",
|
||||||
|
"initializingMap": "Инициализация карты...",
|
||||||
|
"dragToPosition": "Перетащите для позиционирования",
|
||||||
|
"createSarMarker": "Создать маркер SAR",
|
||||||
|
"compass": "Компас",
|
||||||
|
"navigationAndContacts": "Навигация и контакты",
|
||||||
|
"sarAlert": "ТРЕВОГА SAR",
|
||||||
|
"messageSentToPublicChannel": "Сообщение отправлено в публичный канал",
|
||||||
|
"pleaseSelectRoomToSendSar": "Пожалуйста, выберите комнату для отправки маркера SAR",
|
||||||
|
"failedToSendSarMarker": "Не удалось отправить маркер SAR: {error}",
|
||||||
|
"sarMarkerSentTo": "Маркер SAR отправлен в {roomName}",
|
||||||
|
"notConnectedCannotSync": "Нет подключения — синхронизация невозможна",
|
||||||
|
"syncedMessageCount": "Синхронизировано {count} сообщение(й)",
|
||||||
|
"noNewMessages": "Новых сообщений нет",
|
||||||
|
"syncFailed": "Синхронизация не удалась: {error}",
|
||||||
|
"failedToResendMessage": "Не удалось повторно отправить сообщение",
|
||||||
|
"retryingMessage": "Повторная отправка сообщения...",
|
||||||
|
"retryFailed": "Повтор не удался: {error}",
|
||||||
|
"textCopiedToClipboard": "Текст скопирован в буфер обмена",
|
||||||
|
"cannotReplySenderMissing": "Не удаётся ответить: нет информации об отправителе",
|
||||||
|
"cannotReplyContactNotFound": "Не удаётся ответить: контакт не найден",
|
||||||
|
"messageDeleted": "Сообщение удалено",
|
||||||
|
"copyText": "Копировать текст",
|
||||||
|
"saveAsTemplate": "Сохранить как шаблон",
|
||||||
|
"templateSaved": "Шаблон успешно сохранён",
|
||||||
|
"templateAlreadyExists": "Шаблон с таким эмодзи уже существует",
|
||||||
|
"deleteMessage": "Удалить сообщение",
|
||||||
|
"deleteMessageConfirmation": "Вы уверены, что хотите удалить это сообщение?",
|
||||||
|
"shareLocation": "Поделиться местоположением",
|
||||||
|
"shareLocationText": "{markerInfo}\n\nКоординаты: {lat}, {lon}\n\nGoogle Maps: {url}",
|
||||||
|
"sarLocationShare": "Местоположение SAR",
|
||||||
|
"locationShared": "Местоположение передано",
|
||||||
|
"refreshedContacts": "Контакты обновлены",
|
||||||
|
"justNow": "Только что",
|
||||||
|
"minutesAgo": "{minutes}м назад",
|
||||||
|
"hoursAgo": "{hours}ч назад",
|
||||||
|
"daysAgo": "{days}д назад",
|
||||||
|
"secondsAgo": "{seconds}с назад",
|
||||||
|
"sending": "Отправка...",
|
||||||
|
"sent": "Отправлено",
|
||||||
|
"delivered": "Доставлено",
|
||||||
|
"deliveredWithTime": "Доставлено ({time}мс)",
|
||||||
|
"failed": "Ошибка",
|
||||||
|
"broadcast": "Трансляция",
|
||||||
|
"deliveredToContacts": "Доставлено {delivered}/{total} контактам",
|
||||||
|
"allDelivered": "Все доставлено",
|
||||||
|
"recipientDetails": "Детали получателей",
|
||||||
|
"pending": "Ожидание",
|
||||||
|
"sarMarkerFoundPerson": "Найденный человек",
|
||||||
|
"sarMarkerFire": "Место пожара",
|
||||||
|
"sarMarkerStagingArea": "Место сбора",
|
||||||
|
"sarMarkerObject": "Найденный объект",
|
||||||
|
"from": "От",
|
||||||
|
"coordinates": "Координаты",
|
||||||
|
"tapToViewOnMap": "Нажмите, чтобы открыть на карте",
|
||||||
|
"radioSettings": "Настройки радио",
|
||||||
|
"frequencyMHz": "Частота (МГц)",
|
||||||
|
"frequencyExample": "например, 869.618",
|
||||||
|
"bandwidth": "Полоса пропускания",
|
||||||
|
"spreadingFactor": "Коэффициент расширения",
|
||||||
|
"codingRate": "Скорость кодирования",
|
||||||
|
"txPowerDbm": "Мощность TX (дБм)",
|
||||||
|
"maxPowerDbm": "Макс: {power} дБм",
|
||||||
|
"you": "Вы",
|
||||||
|
"offlineVectorMaps": "Офлайн-векторные карты",
|
||||||
|
"offlineVectorMapsDescription": "Импортируйте офлайн-векторные тайлы карт (формат MBTiles) для использования без интернета",
|
||||||
|
"importMbtiles": "Импортировать файл MBTiles",
|
||||||
|
"importMbtilesNote": "Поддерживаются файлы MBTiles с векторными тайлами (формат PBF/MVT). Отлично подходят выгрузки Geofabrik!",
|
||||||
|
"noMbtilesFiles": "Офлайн-векторные карты не найдены",
|
||||||
|
"mbtilesImportedSuccessfully": "Файл MBTiles успешно импортирован",
|
||||||
|
"failedToImportMbtiles": "Не удалось импортировать файл MBTiles",
|
||||||
|
"deleteMbtilesConfirmTitle": "Удалить офлайн-карту",
|
||||||
|
"deleteMbtilesConfirmMessage": "Вы уверены, что хотите удалить \"{name}\"? Офлайн-карта будет удалена безвозвратно.",
|
||||||
|
"mbtilesDeletedSuccessfully": "Офлайн-карта успешно удалена",
|
||||||
|
"failedToDeleteMbtiles": "Не удалось удалить офлайн-карту",
|
||||||
|
"importExportCachedTiles": "Импорт/Экспорт кэшированных тайлов",
|
||||||
|
"importExportDescription": "Резервное копирование, обмен и восстановление загруженных тайлов карт между устройствами",
|
||||||
|
"exportTilesToFile": "Экспортировать тайлы в файл",
|
||||||
|
"importTilesFromFile": "Импортировать тайлы из файла",
|
||||||
|
"selectExportLocation": "Выберите место для экспорта",
|
||||||
|
"selectImportFile": "Выберите архив тайлов",
|
||||||
|
"exportingTiles": "Экспорт тайлов...",
|
||||||
|
"importingTiles": "Импорт тайлов...",
|
||||||
|
"exportSuccess": "Экспортировано {count} тайлов",
|
||||||
|
"importSuccess": "Импортировано {count} хранилищ",
|
||||||
|
"exportFailed": "Ошибка экспорта: {error}",
|
||||||
|
"importFailed": "Ошибка импорта: {error}",
|
||||||
|
"exportNote": "Создаёт сжатый архив (.fmtc), которым можно поделиться и импортировать на других устройствах.",
|
||||||
|
"importNote": "Импортирует тайлы карт из ранее экспортированного архива. Тайлы будут объединены с существующим кэшем.",
|
||||||
|
"noTilesToExport": "Нет доступных тайлов для экспорта",
|
||||||
|
"archiveContainsStores": "Архив содержит {count} хранилищ",
|
||||||
|
"vectorTiles": "Векторные тайлы",
|
||||||
|
"schema": "Схема",
|
||||||
|
"unknown": "Неизвестно",
|
||||||
|
"bounds": "Границы",
|
||||||
|
"onlineLayers": "Онлайн-слои",
|
||||||
|
"offlineLayers": "Офлайн-слои",
|
||||||
|
"locationTrail": "Трек местоположения",
|
||||||
|
"showTrailOnMap": "Показать трек на карте",
|
||||||
|
"trailVisible": "Трек отображается на карте",
|
||||||
|
"trailHiddenRecording": "Трек скрыт (запись продолжается)",
|
||||||
|
"duration": "Продолжительность",
|
||||||
|
"points": "Точки",
|
||||||
|
"clearTrail": "Очистить трек",
|
||||||
|
"clearTrailQuestion": "Очистить трек?",
|
||||||
|
"clearTrailConfirmation": "Вы уверены, что хотите очистить текущий трек? Это действие нельзя отменить.",
|
||||||
|
"noTrailRecorded": "Трек ещё не записан",
|
||||||
|
"startTrackingToRecord": "Запустите отслеживание геолокации для записи трека",
|
||||||
|
"trailControls": "Управление треком",
|
||||||
|
"exportTrailToGpx": "Экспортировать трек в GPX",
|
||||||
|
"importTrailFromGpx": "Импортировать трек из GPX",
|
||||||
|
"trailExportedSuccessfully": "Трек успешно экспортирован!",
|
||||||
|
"failedToExportTrail": "Не удалось экспортировать трек",
|
||||||
|
"failedToImportTrail": "Не удалось импортировать трек: {error}",
|
||||||
|
"importTrail": "Импортировать трек",
|
||||||
|
"importTrailQuestion": "Импортировать трек из {pointCount} точек?\n\nВы можете заменить текущий трек или просмотреть его рядом.",
|
||||||
|
"viewAlongside": "Просмотреть рядом",
|
||||||
|
"replaceCurrent": "Заменить текущий",
|
||||||
|
"trailImported": "Трек импортирован! ({pointCount} точек)",
|
||||||
|
"trailReplaced": "Трек заменён! ({pointCount} точек)",
|
||||||
|
"contactTrails": "Треки контактов",
|
||||||
|
"showAllContactTrails": "Показать все треки контактов",
|
||||||
|
"noContactsWithLocationHistory": "Нет контактов с историей местоположения",
|
||||||
|
"showingTrailsForContacts": "Показаны треки для {count} контактов",
|
||||||
|
"individualContactTrails": "Индивидуальные треки контактов",
|
||||||
|
"deviceInformation": "Информация об устройстве",
|
||||||
|
"bleName": "Имя BLE",
|
||||||
|
"meshName": "Имя в сети Mesh",
|
||||||
|
"notSet": "Не задано",
|
||||||
|
"model": "Модель",
|
||||||
|
"version": "Версия",
|
||||||
|
"buildDate": "Дата сборки",
|
||||||
|
"firmware": "Прошивка",
|
||||||
|
"maxContacts": "Макс. контактов",
|
||||||
|
"maxChannels": "Макс. каналов",
|
||||||
|
"publicInfo": "Публичная информация",
|
||||||
|
"meshNetworkName": "Название сети Mesh",
|
||||||
|
"nameBroadcastInMesh": "Имя, транслируемое в mesh-объявлениях",
|
||||||
|
"telemetryAndLocationSharing": "Телеметрия и передача местоположения",
|
||||||
|
"lat": "Ш",
|
||||||
|
"lon": "Д",
|
||||||
|
"useCurrentLocation": "Использовать текущее местоположение",
|
||||||
|
"noneUnknown": "Нет/Неизвестно",
|
||||||
|
"chatNode": "Узел чата",
|
||||||
|
"repeater": "Ретранслятор",
|
||||||
|
"roomChannel": "Комната/Канал",
|
||||||
|
"typeNumber": "Тип {number}",
|
||||||
|
"copiedToClipboardShort": "{label} скопировано в буфер обмена",
|
||||||
|
"failedToSave": "Не удалось сохранить: {error}",
|
||||||
|
"failedToGetLocation": "Не удалось получить местоположение: {error}",
|
||||||
|
"sarTemplates": "Шаблоны SAR",
|
||||||
|
"manageSarTemplates": "Управление шаблонами целеуказания",
|
||||||
|
"addTemplate": "Добавить шаблон",
|
||||||
|
"editTemplate": "Изменить шаблон",
|
||||||
|
"deleteTemplate": "Удалить шаблон",
|
||||||
|
"templateName": "Название шаблона",
|
||||||
|
"templateNameHint": "например, Найденный человек",
|
||||||
|
"templateEmoji": "Эмодзи",
|
||||||
|
"emojiRequired": "Требуется эмодзи",
|
||||||
|
"nameRequired": "Требуется название",
|
||||||
|
"templateDescription": "Описание (необязательно)",
|
||||||
|
"templateDescriptionHint": "Добавьте дополнительный контекст...",
|
||||||
|
"templateColor": "Цвет",
|
||||||
|
"previewFormat": "Предпросмотр (формат сообщения SAR)",
|
||||||
|
"importFromClipboard": "Импорт",
|
||||||
|
"exportToClipboard": "Экспорт",
|
||||||
|
"deleteTemplateConfirmation": "Удалить шаблон '{name}'?",
|
||||||
|
"templateAdded": "Шаблон добавлен",
|
||||||
|
"templateUpdated": "Шаблон обновлён",
|
||||||
|
"templateDeleted": "Шаблон удалён",
|
||||||
|
"templatesImported": "{count, plural, =0{Шаблоны не импортированы} =1{Импортирован 1 шаблон} other{Импортировано {count} шаблонов}}",
|
||||||
|
"templatesExported": "{count, plural, =1{1 шаблон экспортирован в буфер обмена} other{{count} шаблонов экспортировано в буфер обмена}}",
|
||||||
|
"resetToDefaults": "Сбросить до умолчаний",
|
||||||
|
"resetToDefaultsConfirmation": "Все пользовательские шаблоны будут удалены и восстановлены 4 шаблона по умолчанию. Продолжить?",
|
||||||
|
"reset": "Сбросить",
|
||||||
|
"resetComplete": "Шаблоны сброшены до умолчаний",
|
||||||
|
"noTemplates": "Шаблоны недоступны",
|
||||||
|
"tapAddToCreate": "Нажмите +, чтобы создать первый шаблон",
|
||||||
|
"ok": "ОК",
|
||||||
|
"permissionsSection": "Разрешения",
|
||||||
|
"locationPermission": "Разрешение геолокации",
|
||||||
|
"checking": "Проверка...",
|
||||||
|
"locationPermissionGrantedAlways": "Разрешено (всегда)",
|
||||||
|
"locationPermissionGrantedWhileInUse": "Разрешено (при использовании)",
|
||||||
|
"locationPermissionDeniedTapToRequest": "Запрещено — нажмите для запроса",
|
||||||
|
"locationPermissionPermanentlyDeniedOpenSettings": "Запрещено навсегда — откройте настройки",
|
||||||
|
"locationPermissionDialogContent": "Доступ к геолокации запрещён навсегда. Включите его в настройках устройства для GPS-трекинга и обмена местоположением.",
|
||||||
|
"openSettings": "Открыть настройки",
|
||||||
|
"locationPermissionGranted": "Разрешение геолокации получено!",
|
||||||
|
"locationPermissionRequiredForGps": "Разрешение геолокации необходимо для GPS-трекинга и обмена местоположением.",
|
||||||
|
"locationPermissionAlreadyGranted": "Разрешение геолокации уже предоставлено.",
|
||||||
|
"sarNavyBlue": "SAR Тёмно-синяя",
|
||||||
|
"sarNavyBlueDescription": "Профессиональный / оперативный режим",
|
||||||
|
"selectRecipient": "Выбрать получателя",
|
||||||
|
"broadcastToAllNearby": "Трансляция всем ближайшим",
|
||||||
|
"searchRecipients": "Поиск получателей...",
|
||||||
|
"noContactsFound": "Контакты не найдены",
|
||||||
|
"noRoomsFound": "Комнаты не найдены",
|
||||||
|
"noContactsOrRoomsAvailable": "Нет доступных контактов или комнат",
|
||||||
|
"noRecipientsAvailable": "Нет доступных получателей",
|
||||||
|
"noChannelsFound": "Каналы не найдены",
|
||||||
|
"messagesWillBeSentToPublicChannel": "Сообщения будут отправлены в публичный канал",
|
||||||
|
"newMessage": "Новое сообщение",
|
||||||
|
"channel": "Канал",
|
||||||
|
"samplePoliceLead": "Руководитель группы полиции",
|
||||||
|
"sampleDroneOperator": "Оператор дрона",
|
||||||
|
"sampleFirefighterAlpha": "Пожарный",
|
||||||
|
"sampleMedicCharlie": "Медик",
|
||||||
|
"sampleCommandDelta": "Командование",
|
||||||
|
"sampleFireEngine": "Пожарная машина",
|
||||||
|
"sampleAirSupport": "Авиационная поддержка",
|
||||||
|
"sampleBaseCoordinator": "Базовый координатор",
|
||||||
|
"channelEmergency": "Аварийный",
|
||||||
|
"channelCoordination": "Координация",
|
||||||
|
"channelUpdates": "Обновления",
|
||||||
|
"sampleTeamMember": "Тестовый член команды",
|
||||||
|
"sampleScout": "Тестовый разведчик",
|
||||||
|
"sampleBase": "Тестовая база",
|
||||||
|
"sampleSearcher": "Тестовый поисковик",
|
||||||
|
"sampleObjectBackpack": " Найден рюкзак синего цвета",
|
||||||
|
"sampleObjectVehicle": " Брошенный автомобиль — установить владельца",
|
||||||
|
"sampleObjectCamping": " Обнаружено туристическое снаряжение",
|
||||||
|
"sampleObjectTrailMarker": " Указатель тропы найден вне маршрута",
|
||||||
|
"sampleMsgAllTeamsCheckIn": "Всем группам: сообщить о готовности",
|
||||||
|
"sampleMsgWeatherUpdate": "Погода: ясно, температура 18°C",
|
||||||
|
"sampleMsgBaseCamp": "Базовый лагерь развёрнут у места сбора",
|
||||||
|
"sampleMsgTeamAlpha": "Группа выдвигается в сектор 2",
|
||||||
|
"sampleMsgRadioCheck": "Проверка связи — всем станциям ответить",
|
||||||
|
"sampleMsgWaterSupply": "Вода доступна на контрольной точке 3",
|
||||||
|
"sampleMsgTeamBravo": "Группа докладывает: сектор 1 чист",
|
||||||
|
"sampleMsgEtaRallyPoint": "Прибытие на место сбора: 15 минут",
|
||||||
|
"sampleMsgSupplyDrop": "Сброс снаряжения подтверждён на 14:00",
|
||||||
|
"sampleMsgDroneSurvey": "Разведка дроном завершена — ничего не обнаружено",
|
||||||
|
"sampleMsgTeamCharlie": "Группа запрашивает подкрепление",
|
||||||
|
"sampleMsgRadioDiscipline": "Всем подразделениям: соблюдать радиодисциплину",
|
||||||
|
"sampleMsgUrgentMedical": "СРОЧНО: нужна медицинская помощь в секторе 4",
|
||||||
|
"sampleMsgAdultMale": " Взрослый мужчина, в сознании",
|
||||||
|
"sampleMsgFireSpotted": "Обнаружен пожар — координаты следуют",
|
||||||
|
"sampleMsgSpreadingRapidly": " Распространяется быстро!",
|
||||||
|
"sampleMsgPriorityHelicopter": "ПРИОРИТЕТ: нужна поддержка вертолёта",
|
||||||
|
"sampleMsgMedicalTeamEnRoute": "Медицинская группа движется к вашему местоположению",
|
||||||
|
"sampleMsgEvacHelicopter": "Вертолёт эвакуации: прибытие через 10 минут",
|
||||||
|
"sampleMsgEmergencyResolved": "Чрезвычайная ситуация ликвидирована — опасности нет",
|
||||||
|
"sampleMsgEmergencyStagingArea": " Аварийное место сбора",
|
||||||
|
"sampleMsgEmergencyServices": "Аварийные службы уведомлены и реагируют",
|
||||||
|
"sampleAlphaTeamLead": "Руководитель группы",
|
||||||
|
"sampleBravoScout": "Разведчик",
|
||||||
|
"sampleCharlieMedic": "Медик",
|
||||||
|
"sampleDeltaNavigator": "Навигатор",
|
||||||
|
"sampleEchoSupport": "Поддержка",
|
||||||
|
"sampleBaseCommand": "Базовое командование",
|
||||||
|
"sampleFieldCoordinator": "Полевой координатор",
|
||||||
|
"sampleMedicalTeam": "Медицинская группа",
|
||||||
|
"mapDrawing": "Рисунок на карте",
|
||||||
|
"navigateToDrawing": "Перейти к рисунку",
|
||||||
|
"copyCoordinates": "Копировать координаты",
|
||||||
|
"hideFromMap": "Скрыть с карты",
|
||||||
|
"lineDrawing": "Линия",
|
||||||
|
"rectangleDrawing": "Прямоугольник",
|
||||||
|
"coordinatesCopiedToClipboard": "Координаты скопированы в буфер обмена",
|
||||||
|
"manualCoordinates": "Ввод координат вручную",
|
||||||
|
"enterCoordinatesManually": "Ввести координаты вручную",
|
||||||
|
"latitudeLabel": "Широта",
|
||||||
|
"longitudeLabel": "Долгота",
|
||||||
|
"invalidLatitude": "Неверная широта (от -90 до 90)",
|
||||||
|
"invalidLongitude": "Неверная долгота (от -180 до 180)",
|
||||||
|
"exampleCoordinates": "Пример: 55.7558, 37.6173",
|
||||||
|
"drawingShared": "Рисунок на карте",
|
||||||
|
"drawingHidden": "Рисунок скрыт с карты",
|
||||||
|
"alreadyShared": "{count, plural, =1{1 уже передан} other{{count} уже передано}}",
|
||||||
|
"newDrawingsShared": "Передано {count} новых рисунк{plural}",
|
||||||
|
"shareDrawing": "Поделиться рисунком",
|
||||||
|
"shareWithAllNearbyDevices": "Поделиться со всеми ближайшими устройствами",
|
||||||
|
"shareToRoom": "Отправить в комнату",
|
||||||
|
"sendToPersistentStorage": "Отправить в постоянное хранилище комнаты",
|
||||||
|
"deleteDrawingConfirm": "Вы уверены, что хотите удалить этот рисунок?",
|
||||||
|
"drawingDeleted": "Рисунок удалён",
|
||||||
|
"yourDrawingsCount": "Ваши рисунки ({count})",
|
||||||
|
"shared": "Передано",
|
||||||
|
"line": "Линия",
|
||||||
|
"rectangle": "Прямоугольник",
|
||||||
|
"updateAvailable": "Доступно обновление",
|
||||||
|
"currentVersion": "Текущая",
|
||||||
|
"latestVersion": "Последняя",
|
||||||
|
"downloadUpdate": "Скачать",
|
||||||
|
"updateLater": "Позже",
|
||||||
|
"cadastralParcels": "Кадастровые участки",
|
||||||
|
"forestRoads": "Лесные дороги",
|
||||||
|
"showCadastralParcels": "Показать кадастровые участки",
|
||||||
|
"showForestRoads": "Показать лесные дороги",
|
||||||
|
"wmsOverlays": "WMS-наложения",
|
||||||
|
"hikingTrails": "Туристические маршруты",
|
||||||
|
"mainRoads": "Главные дороги",
|
||||||
|
"houseNumbers": "Номера домов",
|
||||||
|
"fireHazardZones": "Пожароопасные зоны",
|
||||||
|
"historicalFires": "Исторические пожары",
|
||||||
|
"firebreaks": "Противопожарные просеки",
|
||||||
|
"krasFireZones": "Пожарные зоны Краса",
|
||||||
|
"placeNames": "Названия мест",
|
||||||
|
"municipalityBorders": "Границы муниципалитетов",
|
||||||
|
"topographicMap": "Топографическая карта 1:25000",
|
||||||
|
"recentMessages": "Последние сообщения",
|
||||||
|
"addChannel": "Добавить канал",
|
||||||
|
"channelName": "Название канала",
|
||||||
|
"channelNameHint": "например, Группа спасения Альфа",
|
||||||
|
"channelSecret": "Секрет канала",
|
||||||
|
"channelSecretHint": "Общий пароль для этого канала",
|
||||||
|
"channelSecretHelp": "Этот секрет необходимо передать всем членам команды, которым нужен доступ к каналу",
|
||||||
|
"channelTypesInfo": "Hash-каналы (#команда): секрет генерируется из названия автоматически. Одно название = один канал на всех устройствах.\n\nЗакрытые каналы: используется явный секрет. Войти могут только те, у кого есть секрет.",
|
||||||
|
"hashChannelInfo": "Hash-канал: секрет будет автоматически создан из названия. Все, кто использует одно и то же имя, окажутся в одном канале.",
|
||||||
|
"channelNameRequired": "Требуется название канала",
|
||||||
|
"channelNameTooLong": "Название канала не должно превышать 31 символ",
|
||||||
|
"channelSecretRequired": "Требуется секрет канала",
|
||||||
|
"channelSecretTooLong": "Секрет канала не должен превышать 32 символа",
|
||||||
|
"invalidAsciiCharacters": "Разрешены только символы ASCII",
|
||||||
|
"channelCreatedSuccessfully": "Канал успешно создан",
|
||||||
|
"channelCreationFailed": "Не удалось создать канал: {error}",
|
||||||
|
"deleteChannel": "Удалить канал",
|
||||||
|
"deleteChannelConfirmation": "Вы уверены, что хотите удалить канал \"{channelName}\"? Это действие нельзя отменить.",
|
||||||
|
"channelDeletedSuccessfully": "Канал успешно удалён",
|
||||||
|
"channelDeletionFailed": "Не удалось удалить канал: {error}",
|
||||||
|
"allChannelSlotsInUse": "Все слоты каналов заняты (максимум 39 пользовательских каналов)",
|
||||||
|
"createChannel": "Создать канал",
|
||||||
|
"wizardBack": "Назад",
|
||||||
|
"wizardSkip": "Пропустить",
|
||||||
|
"wizardNext": "Далее",
|
||||||
|
"wizardGetStarted": "Начать",
|
||||||
|
"wizardWelcomeTitle": "Добро пожаловать в MeshCore SAR",
|
||||||
|
"wizardWelcomeDescription": "Мощный инструмент связи вне сети для поисково-спасательных операций. Поддерживайте связь с командой через mesh-радио, когда традиционные сети недоступны.",
|
||||||
|
"wizardConnectingTitle": "Подключение к радиоустройству",
|
||||||
|
"wizardConnectingDescription": "Подключите смартфон к радиоустройству MeshCore по Bluetooth для связи вне сети.",
|
||||||
|
"wizardConnectingFeature1": "Поиск ближайших устройств MeshCore",
|
||||||
|
"wizardConnectingFeature2": "Сопряжение с радиоустройством по Bluetooth",
|
||||||
|
"wizardConnectingFeature3": "Работает полностью офлайн — интернет не нужен",
|
||||||
|
"wizardSimpleModeTitle": "Простой режим",
|
||||||
|
"wizardSimpleModeDescription": "Впервые работаете с mesh-сетью? Включите простой режим для упрощённого интерфейса с основными функциями.",
|
||||||
|
"wizardSimpleModeFeature1": "Удобный для новичков интерфейс с основными функциями",
|
||||||
|
"wizardSimpleModeFeature2": "Переключиться в расширенный режим можно в любое время в настройках",
|
||||||
|
"wizardChannelTitle": "Каналы",
|
||||||
|
"wizardChannelDescription": "Транслируйте сообщения всем участникам канала — идеально для общих объявлений и координации команды.",
|
||||||
|
"wizardChannelFeature1": "Публичный канал для общей связи команды",
|
||||||
|
"wizardChannelFeature2": "Создавайте пользовательские каналы для отдельных групп",
|
||||||
|
"wizardChannelFeature3": "Сообщения автоматически ретранслируются через mesh",
|
||||||
|
"wizardContactsTitle": "Контакты",
|
||||||
|
"wizardContactsDescription": "Члены вашей команды появляются автоматически по мере подключения к mesh-сети. Отправляйте им личные сообщения или просматривайте их местоположение.",
|
||||||
|
"wizardContactsFeature1": "Контакты обнаруживаются автоматически",
|
||||||
|
"wizardContactsFeature2": "Отправка личных сообщений",
|
||||||
|
"wizardContactsFeature3": "Просмотр уровня заряда и времени последнего появления",
|
||||||
|
"wizardMapTitle": "Карта и местоположение",
|
||||||
|
"wizardMapDescription": "Отслеживайте команду в реальном времени и отмечайте важные места для поисково-спасательных операций.",
|
||||||
|
"wizardMapFeature1": "Маркеры SAR для найденных людей, пожаров и мест сбора",
|
||||||
|
"wizardMapFeature2": "GPS-отслеживание членов команды в реальном времени",
|
||||||
|
"wizardMapFeature3": "Загрузка офлайн-карт для отдалённых районов",
|
||||||
|
"wizardMapFeature4": "Рисование фигур и обмен тактической информацией",
|
||||||
|
"viewWelcomeTutorial": "Просмотреть обучение",
|
||||||
|
"allTeamContacts": "Все контакты команды",
|
||||||
|
"directMessagesInfo": "Личные сообщения с подтверждением. Отправлено {count} членам команды.",
|
||||||
|
"sarMarkerSentToContacts": "Маркер SAR отправлен {count} контактам",
|
||||||
|
"noContactsAvailable": "Нет доступных контактов команды",
|
||||||
|
"reply": "Ответить",
|
||||||
|
"technicalDetails": "Технические детали",
|
||||||
|
"messageTechnicalDetails": "Технические детали сообщения",
|
||||||
|
"linkQuality": "Качество связи",
|
||||||
|
"delivery": "Доставка",
|
||||||
|
"status": "Статус",
|
||||||
|
"expectedAckTag": "Ожидаемый тег ACK",
|
||||||
|
"roundTrip": "Время отклика",
|
||||||
|
"retryAttempt": "Попытка повтора",
|
||||||
|
"floodFallback": "Широковещательный резерв",
|
||||||
|
"identity": "Идентификатор",
|
||||||
|
"messageId": "ID сообщения",
|
||||||
|
"sender": "Отправитель",
|
||||||
|
"senderKey": "Ключ отправителя",
|
||||||
|
"recipient": "Получатель",
|
||||||
|
"recipientKey": "Ключ получателя",
|
||||||
|
"voice": "Голос",
|
||||||
|
"voiceId": "ID голоса",
|
||||||
|
"envelope": "Конверт",
|
||||||
|
"sessionProgress": "Прогресс сессии",
|
||||||
|
"complete": "Завершено",
|
||||||
|
"rawDump": "Необработанные данные",
|
||||||
|
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
|
||||||
|
"voiceUnavailable": "Голос сейчас недоступен",
|
||||||
|
"requestingVoice": "Запрос голоса"
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import 'providers/channels_provider.dart';
|
|||||||
import 'providers/voice_provider.dart';
|
import 'providers/voice_provider.dart';
|
||||||
import 'providers/image_provider.dart' as ip;
|
import 'providers/image_provider.dart' as ip;
|
||||||
import 'providers/app_provider.dart';
|
import 'providers/app_provider.dart';
|
||||||
|
import 'providers/sensors_provider.dart';
|
||||||
import 'services/voice_codec_service.dart';
|
import 'services/voice_codec_service.dart';
|
||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_service.dart';
|
||||||
import 'services/tile_cache_service.dart';
|
import 'services/tile_cache_service.dart';
|
||||||
@@ -234,6 +235,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||||
|
|
||||||
// Voice provider (packet reassembly + playback)
|
// Voice provider (packet reassembly + playback)
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
|
|||||||
@@ -46,12 +46,19 @@ class ContactRouteCodec {
|
|||||||
static const int maxPathBytes = 64;
|
static const int maxPathBytes = 64;
|
||||||
static const int _unknownDescriptor = 0xFF;
|
static const int _unknownDescriptor = 0xFF;
|
||||||
|
|
||||||
static ParsedContactRoute parse(String input) {
|
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
|
||||||
final normalized = input.trim().toUpperCase();
|
final normalized = input.trim().toUpperCase();
|
||||||
if (normalized.isEmpty) {
|
if (normalized.isEmpty) {
|
||||||
throw const ContactRouteFormatException('Route cannot be empty.');
|
throw const ContactRouteFormatException('Route cannot be empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expectedHashSize != null &&
|
||||||
|
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
|
||||||
|
throw const ContactRouteFormatException(
|
||||||
|
'Hash size must be 1, 2, or 3 bytes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final hopTokens = normalized
|
final hopTokens = normalized
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((token) => token.trim())
|
.map((token) => token.trim())
|
||||||
@@ -80,6 +87,13 @@ class ContactRouteCodec {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
|
||||||
|
throw ContactRouteFormatException(
|
||||||
|
'Hop "$token" must be $expectedHashSize '
|
||||||
|
'byte${expectedHashSize == 1 ? '' : 's'}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
hashSize ??= currentHashSize;
|
hashSize ??= currentHashSize;
|
||||||
if (hashSize != currentHashSize) {
|
if (hashSize != currentHashSize) {
|
||||||
throw const ContactRouteFormatException(
|
throw const ContactRouteFormatException(
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isMapEnabled => _isMapEnabled;
|
bool get isMapEnabled => _isMapEnabled;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool get isContactsEnabled => _isContactsEnabled;
|
bool get isContactsEnabled => _isContactsEnabled;
|
||||||
|
bool _isSensorsEnabled = true;
|
||||||
|
bool get isSensorsEnabled => _isSensorsEnabled;
|
||||||
|
|
||||||
bool _isVoiceSilenceTrimmingEnabled = true;
|
bool _isVoiceSilenceTrimmingEnabled = true;
|
||||||
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
||||||
@@ -97,6 +99,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
_loadSimpleMode();
|
_loadSimpleMode();
|
||||||
_loadMapEnabled();
|
_loadMapEnabled();
|
||||||
_loadContactsEnabled();
|
_loadContactsEnabled();
|
||||||
|
_loadSensorsEnabled();
|
||||||
_loadVoiceSilenceTrimmingEnabled();
|
_loadVoiceSilenceTrimmingEnabled();
|
||||||
_loadVoiceBandPassFilterEnabled();
|
_loadVoiceBandPassFilterEnabled();
|
||||||
_loadVoiceCompressorEnabled();
|
_loadVoiceCompressorEnabled();
|
||||||
@@ -291,6 +294,29 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load sensors enabled setting from shared preferences
|
||||||
|
Future<void> _loadSensorsEnabled() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading sensors enabled setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle sensors tab on/off
|
||||||
|
Future<void> toggleSensorsEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_isSensorsEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('sensors_enabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensors enabled setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load voice silence trimming setting from shared preferences.
|
/// Load voice silence trimming setting from shared preferences.
|
||||||
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
|
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -303,11 +303,28 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// Existing contact - preserve history and isNew status
|
// Existing contact - preserve history and isNew status
|
||||||
final existingContact = _contacts[contact.publicKeyHex]!;
|
final existingContact = _contacts[contact.publicKeyHex]!;
|
||||||
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: existingContact.telemetry,
|
||||||
|
incomingTelemetry: contact.telemetry,
|
||||||
|
);
|
||||||
|
final incomingAdvertLocation = contact.advertLocation;
|
||||||
|
final existingAdvertLocation = existingContact.advertLocation;
|
||||||
|
|
||||||
// Start with existing contact
|
// Start with existing contact
|
||||||
updatedContact = contact.copyWith(
|
updatedContact = contact.copyWith(
|
||||||
isNew: existingContact.isNew,
|
isNew: existingContact.isNew,
|
||||||
advertHistory: existingContact.advertHistory,
|
advertHistory: existingContact.advertHistory,
|
||||||
|
telemetry: mergedTelemetry,
|
||||||
|
advLat: incomingAdvertLocation != null
|
||||||
|
? contact.advLat
|
||||||
|
: existingAdvertLocation != null
|
||||||
|
? existingContact.advLat
|
||||||
|
: contact.advLat,
|
||||||
|
advLon: incomingAdvertLocation != null
|
||||||
|
? contact.advLon
|
||||||
|
: existingAdvertLocation != null
|
||||||
|
? existingContact.advLon
|
||||||
|
: contact.advLon,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add new location to history if location has changed
|
// Add new location to history if location has changed
|
||||||
@@ -397,23 +414,22 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep last valid GPS for router/chat/room contacts when current
|
final previousTelemetry = contact.telemetry;
|
||||||
// telemetry does not provide a valid GPS fix.
|
|
||||||
if (_shouldRetainLastValidGps(contact, telemetry.gpsLocation)) {
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: previousTelemetry,
|
||||||
|
incomingTelemetry: telemetry,
|
||||||
|
);
|
||||||
|
if (mergedTelemetry != null) {
|
||||||
|
if (_shouldRetainLastValidGps(
|
||||||
|
previousTelemetry,
|
||||||
|
telemetry.gpsLocation,
|
||||||
|
)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
||||||
);
|
);
|
||||||
final previousGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
|
}
|
||||||
telemetry = ContactTelemetry(
|
telemetry = mergedTelemetry;
|
||||||
gpsLocation: previousGps,
|
|
||||||
batteryPercentage: telemetry.batteryPercentage,
|
|
||||||
batteryMilliVolts: telemetry.batteryMilliVolts,
|
|
||||||
temperature: telemetry.temperature,
|
|
||||||
timestamp: telemetry.timestamp,
|
|
||||||
humidity: telemetry.humidity,
|
|
||||||
pressure: telemetry.pressure,
|
|
||||||
extraSensorData: telemetry.extraSensorData,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update contact with new telemetry AND last seen time
|
// Update contact with new telemetry AND last seen time
|
||||||
@@ -467,15 +483,12 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldRetainLastValidGps(Contact contact, LatLng? incomingGps) {
|
bool _shouldRetainLastValidGps(
|
||||||
final isSupportedType =
|
ContactTelemetry? existingTelemetry,
|
||||||
contact.isChat || contact.isRepeater || contact.isRoom;
|
LatLng? incomingGps,
|
||||||
if (!isSupportedType) {
|
) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final hasPreviousValidGps =
|
final hasPreviousValidGps =
|
||||||
_getValidGpsOrNull(contact.telemetry?.gpsLocation) != null;
|
_getValidGpsOrNull(existingTelemetry?.gpsLocation) != null;
|
||||||
if (!hasPreviousValidGps) {
|
if (!hasPreviousValidGps) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -483,6 +496,40 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return incomingGps == null;
|
return incomingGps == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ContactTelemetry? _mergeTelemetryForContact({
|
||||||
|
ContactTelemetry? existingTelemetry,
|
||||||
|
ContactTelemetry? incomingTelemetry,
|
||||||
|
}) {
|
||||||
|
if (incomingTelemetry == null) {
|
||||||
|
return existingTelemetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
||||||
|
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
||||||
|
final mergedExtraSensorData = <String, dynamic>{
|
||||||
|
...?existingTelemetry?.extraSensorData,
|
||||||
|
...?incomingTelemetry.extraSensorData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ContactTelemetry(
|
||||||
|
gpsLocation: incomingGps ?? previousGps,
|
||||||
|
batteryPercentage:
|
||||||
|
incomingTelemetry.batteryPercentage ??
|
||||||
|
existingTelemetry?.batteryPercentage,
|
||||||
|
batteryMilliVolts:
|
||||||
|
incomingTelemetry.batteryMilliVolts ??
|
||||||
|
existingTelemetry?.batteryMilliVolts,
|
||||||
|
temperature:
|
||||||
|
incomingTelemetry.temperature ?? existingTelemetry?.temperature,
|
||||||
|
timestamp: incomingTelemetry.timestamp,
|
||||||
|
humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity,
|
||||||
|
pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure,
|
||||||
|
extraSensorData: mergedExtraSensorData.isEmpty
|
||||||
|
? null
|
||||||
|
: mergedExtraSensorData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
||||||
return (coordinate * 1e6).round();
|
return (coordinate * 1e6).round();
|
||||||
}
|
}
|
||||||
|
|||||||
254
lib/providers/sensors_provider.dart
Normal file
254
lib/providers/sensors_provider.dart
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import 'connection_provider.dart';
|
||||||
|
import 'contacts_provider.dart';
|
||||||
|
|
||||||
|
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
||||||
|
|
||||||
|
class SensorsProvider with ChangeNotifier {
|
||||||
|
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||||
|
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||||
|
static const String _fieldSpanKey = 'sensor_field_spans';
|
||||||
|
static const Set<String> _defaultVisibleFields = <String>{
|
||||||
|
'voltage',
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'gps',
|
||||||
|
};
|
||||||
|
|
||||||
|
final List<String> _watchedSensorKeys = <String>[];
|
||||||
|
final Map<String, SensorRefreshState> _refreshStates =
|
||||||
|
<String, SensorRefreshState>{};
|
||||||
|
final Map<String, Set<String>> _visibleFieldsBySensor =
|
||||||
|
<String, Set<String>>{};
|
||||||
|
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
||||||
|
<String, Map<String, int>>{};
|
||||||
|
bool _isLoaded = false;
|
||||||
|
bool _isRefreshingAll = false;
|
||||||
|
|
||||||
|
SensorsProvider() {
|
||||||
|
unawaited(_loadWatchedSensors());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys);
|
||||||
|
bool get isLoaded => _isLoaded;
|
||||||
|
bool get isRefreshingAll => _isRefreshingAll;
|
||||||
|
|
||||||
|
SensorRefreshState stateFor(String publicKeyHex) =>
|
||||||
|
_refreshStates[publicKeyHex] ?? SensorRefreshState.idle;
|
||||||
|
|
||||||
|
Future<void> _loadWatchedSensors() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
||||||
|
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
||||||
|
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
||||||
|
_watchedSensorKeys
|
||||||
|
..clear()
|
||||||
|
..addAll(stored);
|
||||||
|
_visibleFieldsBySensor.clear();
|
||||||
|
_fieldSpansBySensor.clear();
|
||||||
|
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_visibleFieldsBySensor[entry.key] = (entry.value as List<dynamic>)
|
||||||
|
.cast<String>()
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedSpansJson != null && storedSpansJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedSpansJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_fieldSpansBySensor[entry.key] = (entry.value as Map<String, dynamic>)
|
||||||
|
.map((key, value) => MapEntry(key, value as int));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final key in _watchedSensorKeys) {
|
||||||
|
_visibleFieldsBySensor.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
|
);
|
||||||
|
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading watched sensors: $e');
|
||||||
|
} finally {
|
||||||
|
_isLoaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistWatchedSensors() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving watched sensors: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistVisibleMetrics() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final encoded = <String, List<String>>{};
|
||||||
|
for (final entry in _visibleFieldsBySensor.entries) {
|
||||||
|
encoded[entry.key] = entry.value.toList();
|
||||||
|
}
|
||||||
|
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving visible sensor metrics: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistFieldSpans() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_fieldSpanKey, jsonEncode(_fieldSpansBySensor));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor field spans: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||||
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool showsField(String publicKeyHex, String fieldKey) =>
|
||||||
|
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
||||||
|
|
||||||
|
int fieldSpanFor(String publicKeyHex, String fieldKey) {
|
||||||
|
final sensorSpans = _fieldSpansBySensor[publicKeyHex];
|
||||||
|
final span = sensorSpans?[fieldKey] ?? 1;
|
||||||
|
return span == 2 ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleMetric(
|
||||||
|
String publicKeyHex,
|
||||||
|
String fieldKey,
|
||||||
|
bool visible,
|
||||||
|
) async {
|
||||||
|
final visibleFields = _visibleFieldsBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
|
);
|
||||||
|
if (visible) {
|
||||||
|
visibleFields.add(fieldKey);
|
||||||
|
} else {
|
||||||
|
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visibleFields.remove(fieldKey);
|
||||||
|
}
|
||||||
|
await _persistVisibleMetrics();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setFieldSpan(
|
||||||
|
String publicKeyHex,
|
||||||
|
String fieldKey,
|
||||||
|
int span,
|
||||||
|
) async {
|
||||||
|
final sensorSpans = _fieldSpansBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => <String, int>{},
|
||||||
|
);
|
||||||
|
sensorSpans[fieldKey] = span == 2 ? 2 : 1;
|
||||||
|
await _persistFieldSpans();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isWatched(String publicKeyHex) =>
|
||||||
|
_watchedSensorKeys.contains(publicKeyHex);
|
||||||
|
|
||||||
|
Future<void> addSensor(Contact contact) async {
|
||||||
|
if (!contact.isChat && !contact.isRepeater) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||||
|
await _persistWatchedSensors();
|
||||||
|
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
|
||||||
|
_defaultVisibleFields,
|
||||||
|
);
|
||||||
|
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||||
|
await _persistVisibleMetrics();
|
||||||
|
await _persistFieldSpans();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSensor(String publicKeyHex) async {
|
||||||
|
_watchedSensorKeys.remove(publicKeyHex);
|
||||||
|
_refreshStates.remove(publicKeyHex);
|
||||||
|
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||||
|
_fieldSpansBySensor.remove(publicKeyHex);
|
||||||
|
await _persistWatchedSensors();
|
||||||
|
await _persistVisibleMetrics();
|
||||||
|
await _persistFieldSpans();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> availableCandidates(ContactsProvider contactsProvider) {
|
||||||
|
final candidates = <Contact>[
|
||||||
|
...contactsProvider.chatContacts,
|
||||||
|
...contactsProvider.repeaters,
|
||||||
|
];
|
||||||
|
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
||||||
|
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshAll({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) async {
|
||||||
|
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRefreshingAll = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final key in _watchedSensorKeys) {
|
||||||
|
Contact? contact;
|
||||||
|
for (final entry in contactsProvider.contacts) {
|
||||||
|
if (entry.publicKeyHex == key) {
|
||||||
|
contact = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contact == null) {
|
||||||
|
_refreshStates[key] = SensorRefreshState.unavailable;
|
||||||
|
notifyListeners();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshStates[key] = SensorRefreshState.refreshing;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final result = await connectionProvider.smartPing(
|
||||||
|
contactPublicKey: contact.publicKey,
|
||||||
|
hasPath: contact.hasPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
_refreshStates[key] = result.success
|
||||||
|
? SensorRefreshState.success
|
||||||
|
: SensorRefreshState.timeout;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isRefreshingAll = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'messages_tab.dart';
|
import 'messages_tab.dart';
|
||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
|
import 'sensors_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
import 'map_management_screen.dart';
|
import 'map_management_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
@@ -24,7 +25,7 @@ import '../widgets/permission_request_dialog.dart';
|
|||||||
import '../widgets/connection_dialog.dart';
|
import '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
|
|
||||||
enum _HomeTab { messages, contacts, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final Function(AppThemeMode) onThemeChanged;
|
final Function(AppThemeMode) onThemeChanged;
|
||||||
@@ -54,11 +55,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
|
bool _isSensorsEnabled = false;
|
||||||
|
|
||||||
List<_HomeTab> get _enabledTabs {
|
List<_HomeTab> get _enabledTabs {
|
||||||
return [
|
return [
|
||||||
_HomeTab.messages,
|
_HomeTab.messages,
|
||||||
if (_isContactsEnabled) _HomeTab.contacts,
|
if (_isContactsEnabled) _HomeTab.contacts,
|
||||||
|
if (_isSensorsEnabled) _HomeTab.sensors,
|
||||||
if (_isMapEnabled) _HomeTab.map,
|
if (_isMapEnabled) _HomeTab.map,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -77,6 +80,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
_appProvider = context.read<AppProvider>();
|
_appProvider = context.read<AppProvider>();
|
||||||
_isMapEnabled = _appProvider.isMapEnabled;
|
_isMapEnabled = _appProvider.isMapEnabled;
|
||||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||||
|
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
||||||
_appProvider.addListener(_handleAppProviderChanged);
|
_appProvider.addListener(_handleAppProviderChanged);
|
||||||
|
|
||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
@@ -106,6 +110,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
_updateTabController(
|
_updateTabController(
|
||||||
mapEnabled: _appProvider.isMapEnabled,
|
mapEnabled: _appProvider.isMapEnabled,
|
||||||
contactsEnabled: _appProvider.isContactsEnabled,
|
contactsEnabled: _appProvider.isContactsEnabled,
|
||||||
|
sensorsEnabled: _appProvider.isSensorsEnabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +134,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
void _updateTabController({
|
void _updateTabController({
|
||||||
required bool mapEnabled,
|
required bool mapEnabled,
|
||||||
required bool contactsEnabled,
|
required bool contactsEnabled,
|
||||||
|
required bool sensorsEnabled,
|
||||||
}) {
|
}) {
|
||||||
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) {
|
if (_isMapEnabled == mapEnabled &&
|
||||||
|
_isContactsEnabled == contactsEnabled &&
|
||||||
|
_isSensorsEnabled == sensorsEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +155,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
// Update state
|
// Update state
|
||||||
_isMapEnabled = mapEnabled;
|
_isMapEnabled = mapEnabled;
|
||||||
_isContactsEnabled = contactsEnabled;
|
_isContactsEnabled = contactsEnabled;
|
||||||
|
_isSensorsEnabled = sensorsEnabled;
|
||||||
if (!_isMapEnabled) {
|
if (!_isMapEnabled) {
|
||||||
_isMapFullscreen = false;
|
_isMapFullscreen = false;
|
||||||
}
|
}
|
||||||
@@ -183,6 +192,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
context.read<ContactsProvider>().markAllAsViewed();
|
context.read<ContactsProvider>().markAllAsViewed();
|
||||||
break;
|
break;
|
||||||
|
case _HomeTab.sensors:
|
||||||
|
break;
|
||||||
case _HomeTab.map:
|
case _HomeTab.map:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -471,6 +482,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
? () => _navigateToTab(_HomeTab.map)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
case _HomeTab.sensors:
|
||||||
|
return const SensorsTab();
|
||||||
case _HomeTab.map:
|
case _HomeTab.map:
|
||||||
return MapTab(
|
return MapTab(
|
||||||
onFullscreenChanged: (isFullscreen) {
|
onFullscreenChanged: (isFullscreen) {
|
||||||
@@ -532,6 +545,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
icon: const Icon(Icons.map),
|
icon: const Icon(Icons.map),
|
||||||
text: AppLocalizations.of(context)!.map,
|
text: AppLocalizations.of(context)!.map,
|
||||||
);
|
);
|
||||||
|
case _HomeTab.sensors:
|
||||||
|
return const Tab(
|
||||||
|
icon: Icon(Icons.sensors),
|
||||||
|
text: 'Sensors',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
final BackgroundLocationService _backgroundLocationService =
|
final BackgroundLocationService _backgroundLocationService =
|
||||||
BackgroundLocationService();
|
BackgroundLocationService();
|
||||||
bool _isDisposing = false; // Flag to prevent updates during disposal
|
bool _isDisposing = false; // Flag to prevent updates during disposal
|
||||||
|
MapProvider? _mapProvider;
|
||||||
|
|
||||||
// MBTiles layers
|
// MBTiles layers
|
||||||
List<MapLayer> _mbtilesLayers = [];
|
List<MapLayer> _mbtilesLayers = [];
|
||||||
@@ -129,6 +130,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return; // Check if widget is still mounted
|
if (!mounted) return; // Check if widget is still mounted
|
||||||
final mapProvider = context.read<MapProvider>();
|
final mapProvider = context.read<MapProvider>();
|
||||||
|
_mapProvider = mapProvider;
|
||||||
mapProvider.addListener(_handleMapNavigation);
|
mapProvider.addListener(_handleMapNavigation);
|
||||||
// Load WMS overlay state
|
// Load WMS overlay state
|
||||||
mapProvider.loadOverlayState();
|
mapProvider.loadOverlayState();
|
||||||
@@ -438,8 +440,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
// Save map position before disposing
|
// Save map position before disposing
|
||||||
_saveMapPosition();
|
_saveMapPosition();
|
||||||
|
|
||||||
final mapProvider = context.read<MapProvider>();
|
_mapProvider?.removeListener(_handleMapNavigation);
|
||||||
mapProvider.removeListener(_handleMapNavigation);
|
|
||||||
|
|
||||||
// DO NOT stop location tracking - it's managed by AppProvider
|
// DO NOT stop location tracking - it's managed by AppProvider
|
||||||
// Restore the original callback instead of setting to null
|
// Restore the original callback instead of setting to null
|
||||||
|
|||||||
1061
lib/screens/sensors_tab.dart
Normal file
1061
lib/screens/sensors_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import '../services/update_checker_service.dart';
|
|||||||
import '../services/voice_codec_service.dart';
|
import '../services/voice_codec_service.dart';
|
||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
import '../utils/sample_data_generator.dart';
|
import '../utils/sample_data_generator.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
@@ -56,6 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
bool _isCheckingForUpdates = false;
|
bool _isCheckingForUpdates = false;
|
||||||
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
||||||
|
int _routeHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
int _imageMaxSize = ImagePreferences.defaultMaxSize;
|
int _imageMaxSize = ImagePreferences.defaultMaxSize;
|
||||||
int _imageCompression = ImagePreferences.defaultQuality;
|
int _imageCompression = ImagePreferences.defaultQuality;
|
||||||
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
|
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
|
||||||
@@ -77,6 +79,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_initializeLocationService();
|
_initializeLocationService();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadVoiceBitratePreference();
|
_loadVoiceBitratePreference();
|
||||||
|
_loadRouteHashSizePreference();
|
||||||
_loadImagePreferences();
|
_loadImagePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +135,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return '$bitrate bps';
|
return '$bitrate bps';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRouteHashSizePreference() async {
|
||||||
|
final value = await RouteHashPreferences.getHashSize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_routeHashSize = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveRouteHashSizePreference(int value) async {
|
||||||
|
await RouteHashPreferences.setHashSize(value);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_routeHashSize = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadImagePreferences() async {
|
Future<void> _loadImagePreferences() async {
|
||||||
final size = await ImagePreferences.getMaxSize();
|
final size = await ImagePreferences.getMaxSize();
|
||||||
final compression = await ImagePreferences.getCompression();
|
final compression = await ImagePreferences.getCompression();
|
||||||
@@ -658,14 +677,55 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showRouteHashSizeDialog() async {
|
||||||
|
final selected = await showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SimpleDialog(
|
||||||
|
title: const Text('Route path byte size'),
|
||||||
|
children: [
|
||||||
|
for (final value in [1, 2, 3])
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.of(context).pop(value),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('$value byte${value == 1 ? '' : 's'}'),
|
||||||
|
Text(
|
||||||
|
'Use ${value * 2} hex characters per hop',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_routeHashSize == value)
|
||||||
|
Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected == null) return;
|
||||||
|
await _saveRouteHashSizePreference(selected);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||||
children: [
|
children: [
|
||||||
// General Settings Section
|
_buildSectionHeader('Appearance'),
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.general),
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.palette),
|
leading: const Icon(Icons.palette),
|
||||||
title: Text(AppLocalizations.of(context)!.theme),
|
title: Text(AppLocalizations.of(context)!.theme),
|
||||||
@@ -673,10 +733,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showThemeDialog(),
|
onTap: () => _showThemeDialog(),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.language),
|
||||||
|
title: Text(AppLocalizations.of(context)!.language),
|
||||||
|
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showLanguageDialog(),
|
||||||
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.radar),
|
secondary: const Icon(Icons.radar),
|
||||||
title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
|
title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.displayPacketActivity),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.displayPacketActivity,
|
||||||
|
),
|
||||||
value: _showRxTxIndicators,
|
value: _showRxTxIndicators,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -685,6 +754,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await _saveRxTxPreference(value);
|
await _saveRxTxPreference(value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Navigation'),
|
||||||
|
_buildSettingsCard([
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.visibility_off),
|
secondary: const Icon(Icons.visibility_off),
|
||||||
@@ -698,19 +771,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Consumer<AppProvider>(
|
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.person_add_alt_1),
|
|
||||||
title: const Text('Auto-add discovered contacts'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Automatically fetch and add new contacts when they are discovered',
|
|
||||||
),
|
|
||||||
value: appProvider.autoAddDiscoveredContacts,
|
|
||||||
onChanged: (value) async {
|
|
||||||
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.map_outlined),
|
secondary: const Icon(Icons.map_outlined),
|
||||||
@@ -737,12 +797,44 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.sensors),
|
||||||
|
title: const Text('Enable Sensors tab'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show a dedicated tab for watched relay and node telemetry',
|
||||||
|
),
|
||||||
|
value: appProvider.isSensorsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleSensorsEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Messaging'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.person_add_alt_1),
|
||||||
|
title: const Text('Auto-add discovered contacts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Automatically fetch and add new contacts when they are discovered',
|
||||||
|
),
|
||||||
|
value: appProvider.autoAddDiscoveredContacts,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.language),
|
leading: const Icon(Icons.alt_route),
|
||||||
title: Text(AppLocalizations.of(context)!.language),
|
title: const Text('Route path byte size'),
|
||||||
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
|
subtitle: Text(
|
||||||
|
'$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes',
|
||||||
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showLanguageDialog(),
|
onTap: _showRouteHashSizeDialog,
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||||
@@ -753,9 +845,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: const Text('Delete all stored message history'),
|
subtitle: const Text('Delete all stored message history'),
|
||||||
onTap: _clearMessages,
|
onTap: _clearMessages,
|
||||||
),
|
),
|
||||||
const Divider(),
|
]),
|
||||||
|
|
||||||
// Voice Settings Section
|
|
||||||
_buildSectionHeader('Voice'),
|
_buildSectionHeader('Voice'),
|
||||||
Consumer2<AppProvider, ConnectionProvider>(
|
Consumer2<AppProvider, ConnectionProvider>(
|
||||||
builder: (context, appProvider, connectionProvider, child) =>
|
builder: (context, appProvider, connectionProvider, child) =>
|
||||||
@@ -768,6 +859,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.graphic_eq),
|
leading: const Icon(Icons.graphic_eq),
|
||||||
title: const Text('Voice bitrate'),
|
title: const Text('Voice bitrate'),
|
||||||
@@ -823,10 +915,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
]),
|
||||||
|
|
||||||
// Image Settings Section
|
_buildSectionHeader('Images'),
|
||||||
_buildSectionHeader('Image'),
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.photo_size_select_large),
|
leading: const Icon(Icons.photo_size_select_large),
|
||||||
title: const Text('Max image size'),
|
title: const Text('Max image size'),
|
||||||
@@ -837,10 +929,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.tune),
|
leading: const Icon(Icons.tune),
|
||||||
title: const Text('Image compression'),
|
title: const Text('Image compression'),
|
||||||
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
|
subtitle: Text(
|
||||||
|
'$_imageCompression / 90 (higher = smaller file)',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: _imageCompression.toDouble(),
|
value: _imageCompression.toDouble(),
|
||||||
min: 10,
|
min: 10,
|
||||||
@@ -879,14 +973,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await _refreshImageModePreview();
|
await _refreshImageModePreview();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
Consumer<ConnectionProvider>(
|
Consumer<ConnectionProvider>(
|
||||||
builder: (context, connectionProvider, child) =>
|
builder: (context, connectionProvider, child) =>
|
||||||
_buildImageModePreviewCard(connectionProvider),
|
_buildImageModePreviewCard(connectionProvider),
|
||||||
),
|
),
|
||||||
const Divider(),
|
_buildSectionHeader('Templates & Help'),
|
||||||
|
_buildSettingsCard([
|
||||||
// Templates Section
|
|
||||||
_buildSectionHeader('Templates'),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.location_searching),
|
leading: const Icon(Icons.location_searching),
|
||||||
title: Text(AppLocalizations.of(context)!.sarTemplates),
|
title: Text(AppLocalizations.of(context)!.sarTemplates),
|
||||||
@@ -906,13 +999,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
|
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
// Show wizard without resetting state - just as a modal
|
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => WelcomeWizardScreen(
|
builder: (context) => WelcomeWizardScreen(
|
||||||
onCompleted: () {
|
onCompleted: () {
|
||||||
// Just pop back to settings when done
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -920,14 +1011,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
]),
|
||||||
|
|
||||||
// Network Sharing Section
|
_buildSectionHeader('Network Sharing'),
|
||||||
const ConnectionModeSelector(),
|
const ConnectionModeSelector(),
|
||||||
const Divider(),
|
|
||||||
|
|
||||||
// Permissions Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||||
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.location_on),
|
leading: const Icon(Icons.location_on),
|
||||||
title: Text(AppLocalizations.of(context)!.locationPermission),
|
title: Text(AppLocalizations.of(context)!.locationPermission),
|
||||||
@@ -977,10 +1067,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _handleLocationPermissionTap(),
|
onTap: () => _handleLocationPermissionTap(),
|
||||||
),
|
),
|
||||||
const Divider(),
|
]),
|
||||||
|
|
||||||
// About Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.about),
|
_buildSectionHeader(AppLocalizations.of(context)!.about),
|
||||||
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.info),
|
leading: const Icon(Icons.info),
|
||||||
title: Text(AppLocalizations.of(context)!.appVersion),
|
title: Text(AppLocalizations.of(context)!.appVersion),
|
||||||
@@ -990,10 +1080,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
: 'Loading...',
|
: 'Loading...',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Check for Updates button (Android only)
|
ListTile(
|
||||||
|
leading: const Icon(Icons.badge),
|
||||||
|
title: Text(AppLocalizations.of(context)!.appName),
|
||||||
|
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.description),
|
||||||
|
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
|
||||||
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
|
||||||
|
),
|
||||||
|
onTap: () => _showAboutDialog(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
if (Platform.isAndroid)
|
if (Platform.isAndroid)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _isCheckingForUpdates ? null : _checkForUpdates,
|
onPressed: _isCheckingForUpdates ? null : _checkForUpdates,
|
||||||
icon: _isCheckingForUpdates
|
icon: _isCheckingForUpdates
|
||||||
@@ -1014,34 +1117,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
_buildSectionHeader('Developer & Data'),
|
||||||
leading: const Icon(Icons.badge),
|
_buildSettingsCard([
|
||||||
title: Text(AppLocalizations.of(context)!.appName),
|
|
||||||
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.description),
|
|
||||||
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
|
|
||||||
subtitle: Text(
|
|
||||||
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
|
|
||||||
),
|
|
||||||
onTap: () => _showAboutDialog(),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
|
|
||||||
// Developer Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.developer),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.bug_report),
|
leading: const Icon(Icons.bug_report),
|
||||||
title: Text(AppLocalizations.of(context)!.packageName),
|
title: Text(AppLocalizations.of(context)!.packageName),
|
||||||
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
|
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
|
||||||
),
|
),
|
||||||
const Divider(),
|
|
||||||
|
|
||||||
// Sample Data Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.sampleData),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context)!.sampleData,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context)!.sampleDataDescription,
|
AppLocalizations.of(context)!.sampleDataDescription,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
@@ -1052,7 +1143,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1086,6 +1177,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1093,7 +1185,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
Widget _buildSectionHeader(String title) {
|
Widget _buildSectionHeader(String title) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
@@ -1104,6 +1196,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSettingsCard(List<Widget> children) {
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: ListTile.divideTiles(
|
||||||
|
context: context,
|
||||||
|
tiles: children,
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.4),
|
||||||
|
).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) {
|
Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) {
|
||||||
final sourceBytes = _previewSourceBytes;
|
final sourceBytes = _previewSourceBytes;
|
||||||
final fileName = _previewSourceName ?? 'No image selected';
|
final fileName = _previewSourceName ?? 'No image selected';
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class LocalePreferences {
|
|||||||
Locale('fr'), // French
|
Locale('fr'), // French
|
||||||
Locale('it'), // Italian
|
Locale('it'), // Italian
|
||||||
Locale('el'), // Greek
|
Locale('el'), // Greek
|
||||||
|
Locale('ru'), // Russian
|
||||||
|
Locale('zh'), // Chinese
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Get the saved locale or return null to use system locale
|
/// Get the saved locale or return null to use system locale
|
||||||
@@ -64,6 +66,8 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Greek';
|
return 'Greek';
|
||||||
|
case 'ru':
|
||||||
|
return 'Русский';
|
||||||
case 'zh':
|
case 'zh':
|
||||||
return '简体中文';
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
@@ -90,6 +94,8 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Ελληνικά';
|
return 'Ελληνικά';
|
||||||
|
case 'ru':
|
||||||
|
return 'Русский';
|
||||||
case 'zh':
|
case 'zh':
|
||||||
return '简体中文';
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ class NotificationService {
|
|||||||
requestAlertPermission: true,
|
requestAlertPermission: true,
|
||||||
requestBadgePermission: true,
|
requestBadgePermission: true,
|
||||||
requestSoundPermission: true,
|
requestSoundPermission: true,
|
||||||
requestCriticalPermission: true, // For urgent SAR notifications
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combined initialization settings
|
// Combined initialization settings
|
||||||
@@ -100,8 +99,6 @@ class NotificationService {
|
|||||||
alert: true,
|
alert: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
sound: true,
|
sound: true,
|
||||||
critical:
|
|
||||||
true, // Request critical alert permission for urgent SAR notifications
|
|
||||||
);
|
);
|
||||||
_permissionGranted = granted ?? false;
|
_permissionGranted = granted ?? false;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -274,8 +271,7 @@ class NotificationService {
|
|||||||
badgeNumber: 1,
|
badgeNumber: 1,
|
||||||
threadIdentifier: 'sar_markers',
|
threadIdentifier: 'sar_markers',
|
||||||
categoryIdentifier: 'SAR_ALERT',
|
categoryIdentifier: 'SAR_ALERT',
|
||||||
interruptionLevel:
|
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||||
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combined notification details
|
// Combined notification details
|
||||||
|
|||||||
26
lib/services/route_hash_preferences.dart
Normal file
26
lib/services/route_hash_preferences.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class RouteHashPreferences {
|
||||||
|
static const String _hashSizeKey = 'route_hash_size';
|
||||||
|
static const int defaultHashSize = 1;
|
||||||
|
|
||||||
|
static Future<int> getHashSize() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize;
|
||||||
|
return _normalize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setHashSize(int value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_hashSizeKey, _normalize(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int normalizeSync(int value) => _normalize(value);
|
||||||
|
|
||||||
|
static int _normalize(int value) {
|
||||||
|
if (value < 1 || value > 3) {
|
||||||
|
return defaultHashSize;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
import '../../services/route_hash_preferences.dart';
|
||||||
|
|
||||||
class ContactRouteDialog extends StatefulWidget {
|
class ContactRouteDialog extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
@@ -17,12 +18,21 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
required Contact contact,
|
required Contact contact,
|
||||||
required List<Contact> availableContacts,
|
required List<Contact> availableContacts,
|
||||||
}) {
|
}) {
|
||||||
return showDialog<ParsedContactRoute>(
|
return showModalBottomSheet<ParsedContactRoute>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ContactRouteDialog(
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: ContactRouteDialog(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
availableContacts: availableContacts,
|
availableContacts: availableContacts,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,20 +42,18 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
late int _selectedHashSize;
|
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
ParsedContactRoute? _parsedRoute;
|
ParsedContactRoute? _parsedRoute;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedHashSize = widget.contact.routeHasPath
|
|
||||||
? widget.contact.routeHashSize
|
|
||||||
: 1;
|
|
||||||
_controller = TextEditingController(
|
_controller = TextEditingController(
|
||||||
text: widget.contact.routeCanonicalText,
|
text: widget.contact.routeCanonicalText,
|
||||||
);
|
);
|
||||||
_controller.addListener(_reparse);
|
_controller.addListener(_reparse);
|
||||||
|
_loadHashSizePreference();
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +76,12 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = ContactRouteCodec.parse(input);
|
final parsed = ContactRouteCodec.parse(
|
||||||
|
input,
|
||||||
|
expectedHashSize: _selectedHashSize,
|
||||||
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_parsedRoute = parsed;
|
_parsedRoute = parsed;
|
||||||
_selectedHashSize = parsed.hashSize;
|
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
});
|
});
|
||||||
} on ContactRouteFormatException catch (error) {
|
} on ContactRouteFormatException catch (error) {
|
||||||
@@ -82,6 +92,15 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHashSizePreference() async {
|
||||||
|
final hashSize = await RouteHashPreferences.getHashSize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedHashSize = hashSize;
|
||||||
|
});
|
||||||
|
_reparse();
|
||||||
|
}
|
||||||
|
|
||||||
String _tokenFor(Contact contact, int hashSize) {
|
String _tokenFor(Contact contact, int hashSize) {
|
||||||
final hex = contact.publicKeyHex.toUpperCase();
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
final length = hashSize * 2;
|
final length = hashSize * 2;
|
||||||
@@ -108,37 +127,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||||
|
|
||||||
return AlertDialog(
|
return FractionallySizedBox(
|
||||||
title: Text('Set Route for ${widget.contact.displayName}'),
|
heightFactor: 0.85,
|
||||||
content: SizedBox(
|
child: Padding(
|
||||||
width: 560,
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Path hash size',
|
'Set Route for ${widget.contact.displayName}',
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [1, 2, 3]
|
|
||||||
.map(
|
|
||||||
(hashSize) => ChoiceChip(
|
|
||||||
label: Text(
|
|
||||||
'$hashSize byte${hashSize == 1 ? '' : 's'}',
|
|
||||||
),
|
|
||||||
selected: _selectedHashSize == hashSize,
|
|
||||||
onSelected: (_) {
|
|
||||||
setState(() {
|
|
||||||
_selectedHashSize = hashSize;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -152,7 +150,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
? 'AABB,CCDD'
|
? 'AABB,CCDD'
|
||||||
: 'AABBCC,DDEEFF',
|
: 'AABBCC,DDEEFF',
|
||||||
helperText:
|
helperText:
|
||||||
'Use comma-separated hops. Colon form like AA:BB is also accepted.',
|
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||||
errorText: _errorText,
|
errorText: _errorText,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
@@ -184,10 +182,8 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
'No repeater or room contacts are available for route building.',
|
'No repeater or room contacts are available for route building.',
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ConstrainedBox(
|
Expanded(
|
||||||
constraints: const BoxConstraints(maxHeight: 240),
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: routeCandidates.length,
|
itemCount: routeCandidates.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final candidate = routeCandidates[index];
|
final candidate = routeCandidates[index];
|
||||||
@@ -209,15 +205,14 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 16),
|
||||||
),
|
Row(
|
||||||
),
|
children: [
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _parsedRoute == null
|
onPressed: _parsedRoute == null
|
||||||
? null
|
? null
|
||||||
@@ -225,6 +220,10 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
child: const Text('Set Route'),
|
child: const Text('Set Route'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
contact.telemetry != null && contact.telemetry!.isRecent;
|
contact.telemetry != null && contact.telemetry!.isRecent;
|
||||||
final battery = contact.displayBattery;
|
final battery = contact.displayBattery;
|
||||||
final location = contact.displayLocation;
|
final location = contact.displayLocation;
|
||||||
final routeHasPath = contact.routeHasPath;
|
|
||||||
|
|
||||||
// Calculate distance if both positions are available
|
// Calculate distance if both positions are available
|
||||||
String? distanceText;
|
String? distanceText;
|
||||||
if (location != null &&
|
if (location != null &&
|
||||||
@@ -77,148 +75,89 @@ class ContactTile extends StatelessWidget {
|
|||||||
final roomLoginState = contact.type == ContactType.room
|
final roomLoginState = contact.type == ContactType.room
|
||||||
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
||||||
: null;
|
: null;
|
||||||
|
final trailing = contact.isChannel && !contact.isPublicChannel
|
||||||
return Card(
|
? PopupMenuButton<String>(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
icon: const Icon(Icons.more_vert),
|
||||||
child: ListTile(
|
onSelected: (value) {
|
||||||
leading: Stack(
|
if (value == 'delete') {
|
||||||
children: [
|
_showDeleteChannelDialog(context, contact);
|
||||||
CircleAvatar(
|
}
|
||||||
backgroundColor: _getTypeColor(contact.type, context),
|
},
|
||||||
child: contact.roleEmoji != null
|
itemBuilder: (context) => [
|
||||||
? Text(
|
PopupMenuItem(
|
||||||
contact.roleEmoji!,
|
value: 'delete',
|
||||||
style: const TextStyle(fontSize: 24),
|
|
||||||
)
|
|
||||||
: Icon(_getTypeIcon(contact.type), color: Colors.white),
|
|
||||||
),
|
|
||||||
// New contact indicator badge (top-right)
|
|
||||||
if (contact.isNew)
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Container(
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blue,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Room login status indicator badge (bottom-right)
|
|
||||||
if (contact.type == ContactType.room && roomLoginState != null)
|
|
||||||
Positioned(
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _getRoomStatusColor(roomLoginState),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_getRoomStatusIcon(roomLoginState),
|
|
||||||
size: 12,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
contact.displayName,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Battery indicator - hidden in simple mode
|
|
||||||
if (!isSimpleMode && battery != null) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
BatteryDisplayHelper.getBatteryIcon(battery),
|
|
||||||
size: 16,
|
|
||||||
color: BatteryDisplayHelper.getBatteryColor(battery),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'${battery.round()}%',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: BatteryDisplayHelper.getBatteryColor(battery),
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Connection type indicator (direct/flood) - hidden in simple mode
|
|
||||||
if (!isSimpleMode && contact.type != ContactType.channel) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: routeHasPath
|
|
||||||
? Colors.green.withValues(alpha: 0.15)
|
|
||||||
: Colors.orange.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
border: Border.all(
|
|
||||||
color: routeHasPath ? Colors.green : Colors.orange,
|
|
||||||
width: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(Icons.delete, color: Colors.red, size: 20),
|
||||||
routeHasPath ? Icons.route : Icons.waves,
|
const SizedBox(width: 8),
|
||||||
size: 10,
|
|
||||||
color: routeHasPath ? Colors.green : Colors.orange,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
Text(
|
||||||
routeHasPath
|
AppLocalizations.of(context)!.deleteChannel,
|
||||||
? AppLocalizations.of(context)!.direct
|
style: const TextStyle(color: Colors.red),
|
||||||
: AppLocalizations.of(context)!.flood,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: routeHasPath ? Colors.green : Colors.orange,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isPingInProgress) ...[
|
)
|
||||||
const SizedBox(width: 6),
|
: null;
|
||||||
SizedBox(
|
void handleTap() {
|
||||||
width: 14,
|
if (isSimpleMode && contact.type == ContactType.chat) {
|
||||||
height: 14,
|
_showSetRouteDialog(context, contact);
|
||||||
child: CircularProgressIndicator(
|
} else if (isSimpleMode && contact.type == ContactType.repeater) {
|
||||||
strokeWidth: 2,
|
_jumpToMapForRepeater(context, contact);
|
||||||
color: Theme.of(context).colorScheme.primary,
|
} else if (isSimpleMode &&
|
||||||
),
|
contact.type == ContactType.room &&
|
||||||
),
|
!contact.isPublicChannel) {
|
||||||
],
|
_showRoomLoginDialog(context, contact);
|
||||||
],
|
} else {
|
||||||
),
|
_showContactDetails(context, contact);
|
||||||
subtitle: isSimpleMode
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final onLongPress = isPingInProgress
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final hasPath = contact.routeHasPath;
|
||||||
|
|
||||||
|
final result = await connectionProvider.smartPing(
|
||||||
|
contactPublicKey: contact.publicKey,
|
||||||
|
hasPath: hasPath,
|
||||||
|
onRetryWithFlooding: () {
|
||||||
|
if (context.mounted) {
|
||||||
|
ToastLogger.warning(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.directPingTimeout(contact.displayName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted && !result.success) {
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.pingFailed(contact.displayName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
);
|
||||||
|
final subtitleWidget = isSimpleMode
|
||||||
? Column(
|
? Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
// Simple mode: Show last update time
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.access_time,
|
Icons.access_time,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: contact.isRecentlySeen
|
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||||
? Colors.green
|
|
||||||
: Colors.grey,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -227,16 +166,11 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
// Simple mode: Show location and distance
|
|
||||||
if (location != null) ...[
|
if (location != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(Icons.location_on, size: 12, color: Colors.blue),
|
||||||
Icons.location_on,
|
|
||||||
size: 12,
|
|
||||||
color: Colors.blue,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -247,41 +181,37 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (distanceText != null) ...[
|
if (contact.type != ContactType.channel ||
|
||||||
const SizedBox(height: 4),
|
distanceText != null) ...[
|
||||||
Row(
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
if (contact.type != ContactType.channel)
|
||||||
Icons.straighten,
|
_buildRoutePill(context, contact),
|
||||||
size: 12,
|
if (distanceText != null)
|
||||||
color: Colors.blue,
|
_buildDistancePill(context, distanceText),
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Colors.blue,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
] else
|
] else
|
||||||
Text(
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
AppLocalizations.of(context)!.noGpsData,
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
// Room login status badges
|
|
||||||
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
|
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -353,15 +283,12 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
],
|
],
|
||||||
// Last seen + GPS info combined
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.access_time,
|
Icons.access_time,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: contact.isRecentlySeen
|
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||||
? Colors.green
|
|
||||||
: Colors.grey,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -373,11 +300,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (hasTelemetry)
|
if (hasTelemetry)
|
||||||
const Icon(
|
const Icon(Icons.sensors, size: 12, color: Colors.green)
|
||||||
Icons.sensors,
|
|
||||||
size: 12,
|
|
||||||
color: Colors.green,
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.sensors_off,
|
Icons.sensors_off,
|
||||||
@@ -396,11 +319,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Icon(
|
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||||
Icons.sensors_off,
|
|
||||||
size: 12,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.noGpsData,
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
@@ -409,107 +328,207 @@ class ContactTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Distance info (new row)
|
if (contact.type != ContactType.channel ||
|
||||||
if (distanceText != null) ...[
|
distanceText != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
if (contact.type != ContactType.channel)
|
||||||
Icons.straighten,
|
_buildRoutePill(context, contact),
|
||||||
size: 12,
|
if (distanceText != null)
|
||||||
color: Colors.blue,
|
_buildDistancePill(context, distanceText),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surfaceContainerLow,
|
||||||
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.045),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
onTap: handleTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
_getTypeColor(contact.type, context),
|
||||||
|
_getTypeColor(
|
||||||
|
contact.type,
|
||||||
|
context,
|
||||||
|
).withValues(alpha: 0.72),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: _getTypeColor(
|
||||||
|
contact.type,
|
||||||
|
context,
|
||||||
|
).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: contact.roleEmoji != null
|
||||||
|
? Text(
|
||||||
|
contact.roleEmoji!,
|
||||||
|
style: const TextStyle(fontSize: 22),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_getTypeIcon(contact.type),
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (contact.isNew)
|
||||||
|
Positioned(
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (contact.type == ContactType.room &&
|
||||||
|
roomLoginState != null)
|
||||||
|
Positioned(
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getRoomStatusColor(roomLoginState),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_getRoomStatusIcon(roomLoginState),
|
||||||
|
size: 11,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
contact.displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: titleStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.75),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
contact.type.displayName,
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Colors.blue,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isSimpleMode && battery != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildBatteryBadge(context, battery),
|
||||||
|
],
|
||||||
|
if (isPingInProgress) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
if (trailing != null) ...[
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
trailing,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: contact.isChannel && !contact.isPublicChannel
|
subtitleWidget,
|
||||||
? PopupMenuButton<String>(
|
|
||||||
icon: const Icon(Icons.more_vert),
|
|
||||||
onSelected: (value) {
|
|
||||||
if (value == 'delete') {
|
|
||||||
_showDeleteChannelDialog(context, contact);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'delete',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.delete, color: Colors.red, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.deleteChannel,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
: null,
|
),
|
||||||
onTap: () {
|
),
|
||||||
// In simple mode, tap directly opens the route editor for chat contacts
|
|
||||||
if (isSimpleMode && contact.type == ContactType.chat) {
|
|
||||||
_showSetRouteDialog(context, contact);
|
|
||||||
} else if (isSimpleMode && contact.type == ContactType.repeater) {
|
|
||||||
// In simple mode, tapping a repeater jumps to the map
|
|
||||||
_jumpToMapForRepeater(context, contact);
|
|
||||||
} else if (isSimpleMode &&
|
|
||||||
contact.type == ContactType.room &&
|
|
||||||
!contact.isPublicChannel) {
|
|
||||||
_showRoomLoginDialog(context, contact);
|
|
||||||
} else {
|
|
||||||
_showContactDetails(context, contact);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress: isPingInProgress
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
|
|
||||||
// Determine if we should use flooding (no path) or direct (has path)
|
|
||||||
final hasPath = contact.routeHasPath;
|
|
||||||
|
|
||||||
// Use smart ping with automatic fallback
|
|
||||||
final result = await connectionProvider.smartPing(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
hasPath: hasPath,
|
|
||||||
onRetryWithFlooding: () {
|
|
||||||
// Called when retrying with flooding after direct timeout
|
|
||||||
if (context.mounted) {
|
|
||||||
ToastLogger.warning(
|
|
||||||
context,
|
|
||||||
AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.directPingTimeout(contact.displayName),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show final result
|
|
||||||
if (context.mounted) {
|
|
||||||
if (!result.success) {
|
|
||||||
ToastLogger.error(
|
|
||||||
context,
|
|
||||||
AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.pingFailed(contact.displayName),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -874,7 +893,8 @@ class ContactTile extends StatelessWidget {
|
|||||||
: () async {
|
: () async {
|
||||||
final connectionProvider = context
|
final connectionProvider = context
|
||||||
.read<ConnectionProvider>();
|
.read<ConnectionProvider>();
|
||||||
final result = await connectionProvider.smartPing(
|
final result = await connectionProvider
|
||||||
|
.smartPing(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
hasPath: contact.routeHasPath,
|
hasPath: contact.routeHasPath,
|
||||||
);
|
);
|
||||||
@@ -1388,4 +1408,116 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildRoutePill(BuildContext context, Contact contact) {
|
||||||
|
final hasPath = contact.routeHasPath;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final textColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
||||||
|
final iconColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.7);
|
||||||
|
final label = !hasPath
|
||||||
|
? AppLocalizations.of(context)!.flood
|
||||||
|
: contact.routeHopCount <= 0
|
||||||
|
? AppLocalizations.of(context)!.direct
|
||||||
|
: contact.routeCanonicalText;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
hasPath ? Icons.alt_route : Icons.waves,
|
||||||
|
size: 11,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: hasPath && contact.routeHopCount > 0
|
||||||
|
? 'monospace'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDistancePill(BuildContext context, String distanceText) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.straighten,
|
||||||
|
size: 11,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBatteryBadge(BuildContext context, double battery) {
|
||||||
|
final color = BatteryDisplayHelper.getBatteryColor(battery);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
BatteryDisplayHelper.getBatteryIcon(battery),
|
||||||
|
size: 12,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${battery.round()}%',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import geolocator_apple
|
|||||||
import nsd_macos
|
import nsd_macos
|
||||||
import objectbox_flutter_libs
|
import objectbox_flutter_libs
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
|
||||||
import record_macos
|
import record_macos
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -35,7 +34,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
|||||||
82
pubspec.lock
82
pubspec.lock
@@ -129,6 +129,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -507,34 +515,34 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
|
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "20.1.0"
|
version: "21.0.0"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.0"
|
version: "8.0.0"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.0"
|
version: "11.0.0"
|
||||||
flutter_local_notifications_windows:
|
flutter_local_notifications_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_windows
|
name: flutter_local_notifications_windows
|
||||||
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
|
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "3.0.0"
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -638,6 +646,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.5"
|
version: "0.2.5"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
gsettings:
|
gsettings:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -646,6 +662,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.8"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -822,6 +846,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.2"
|
version: "2.6.2"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -879,6 +911,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.5"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -951,6 +991,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.1"
|
version: "4.3.1"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -995,10 +1043,10 @@ packages:
|
|||||||
dependency: "direct overridden"
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.1"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1135,6 +1183,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
record:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1424,10 +1480,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.1"
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1647,4 +1703,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.0"
|
flutter: ">=3.38.1"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0307.3+16
|
version: 2026.0307.6+19
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -111,8 +111,8 @@ dependencies:
|
|||||||
flutter_background_service: ^5.0.13
|
flutter_background_service: ^5.0.13
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^20.1.0
|
flutter_local_notifications: ^21.0.0
|
||||||
timezone: ^0.10.0
|
timezone: ^0.11.0
|
||||||
|
|
||||||
# Vibration
|
# Vibration
|
||||||
vibration: ^3.1.4
|
vibration: ^3.1.4
|
||||||
@@ -147,7 +147,7 @@ dev_dependencies:
|
|||||||
fake_async: ^1.3.3
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
path_provider_foundation: 2.5.1
|
path_provider_foundation: 2.6.0
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
android: "launcher_icon"
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects routes that do not match the configured hash size', () {
|
||||||
|
expect(
|
||||||
|
() => ContactRouteCodec.parse('AABB,CCDD', expectedHashSize: 1),
|
||||||
|
throwsA(isA<ContactRouteFormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects invalid tokens', () {
|
test('rejects invalid tokens', () {
|
||||||
expect(
|
expect(
|
||||||
() => ContactRouteCodec.parse('AA,XYZ'),
|
() => ContactRouteCodec.parse('AA,XYZ'),
|
||||||
|
|||||||
@@ -133,12 +133,13 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'retains last valid gps for chat/repeater/room when telemetry gps is invalid or missing',
|
'retains last valid gps for any contact when telemetry gps is invalid or missing',
|
||||||
() {
|
() {
|
||||||
final contactTypes = <ContactType>[
|
final contactTypes = <ContactType>[
|
||||||
ContactType.chat,
|
ContactType.chat,
|
||||||
ContactType.repeater,
|
ContactType.repeater,
|
||||||
ContactType.room,
|
ContactType.room,
|
||||||
|
ContactType.channel,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (var i = 0; i < contactTypes.length; i++) {
|
for (var i = 0; i < contactTypes.length; i++) {
|
||||||
@@ -197,6 +198,49 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'retains last known gps when a contact refresh arrives without location',
|
||||||
|
() {
|
||||||
|
final firstFix = CayenneLppParser.createGpsData(
|
||||||
|
latitude: 45.1234,
|
||||||
|
longitude: 13.8765,
|
||||||
|
);
|
||||||
|
provider.updateTelemetry(publicKey.sublist(0, 6), firstFix);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'Test Contact',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.telemetry, isNotNull);
|
||||||
|
expect(updated.telemetry!.gpsLocation, isNotNull);
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.gpsLocation!.latitude,
|
||||||
|
closeTo(45.1234, 0.0001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.gpsLocation!.longitude,
|
||||||
|
closeTo(13.8765, 0.0001),
|
||||||
|
);
|
||||||
|
expect(updated.advLat, equals((45.1234 * 1e6).round()));
|
||||||
|
expect(updated.advLon, equals((13.8765 * 1e6).round()));
|
||||||
|
expect(updated.displayLocation, isNotNull);
|
||||||
|
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
|
||||||
|
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('builds message snapshot from latest valid telemetry', () {
|
test('builds message snapshot from latest valid telemetry', () {
|
||||||
final telemetryData = CayenneLppParser.createGpsData(
|
final telemetryData = CayenneLppParser.createGpsData(
|
||||||
latitude: 45.0001,
|
latitude: 45.0001,
|
||||||
@@ -235,7 +279,9 @@ void main() {
|
|||||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persists last valid telemetry gps on the contact across reloads', () async {
|
test(
|
||||||
|
'persists last valid telemetry gps on the contact across reloads',
|
||||||
|
() async {
|
||||||
final telemetryData = CayenneLppParser.createGpsData(
|
final telemetryData = CayenneLppParser.createGpsData(
|
||||||
latitude: 45.0001,
|
latitude: 45.0001,
|
||||||
longitude: 13.9999,
|
longitude: 13.9999,
|
||||||
@@ -253,7 +299,8 @@ void main() {
|
|||||||
expect(reloaded.advertLocation, isNotNull);
|
expect(reloaded.advertLocation, isNotNull);
|
||||||
expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001));
|
expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001));
|
||||||
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
|
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ContactsProvider route updates', () {
|
group('ContactsProvider route updates', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user