Compare commits

...

13 Commits

Author SHA1 Message Date
Janez T
79c8664d04 Remove iOS critical alert flags 2026-03-07 20:09:07 +01:00
Janez T
162376cc47 Add ITSAppUsesNonExemptEncryption 2026-03-07 20:05:31 +01:00
Janez Troha
adb8fe32f4 Merge pull request #14 from ydev83/main
Add Russian localization
2026-03-07 20:03:45 +01:00
Janez T
5966ddfbc1 Fix annoying plist reset 2026-03-07 20:00:32 +01:00
Janez T
1867f815d2 Modernize contact list pills 2026-03-07 18:41:37 +01:00
Janez T
4010680da0 Fix contact route parse usage 2026-03-07 18:36:38 +01:00
Janez T
6bfc08f3ae Keep contact GPS location saved 2026-03-07 18:30:36 +01:00
Janez Troha
fa37a20fc7 Merge pull request #15 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-07 18:08:49 +01:00
MGJ
09f61740d2 Merge remote-tracking branch 'origin/main' 2026-03-07 22:18:06 +08:00
MGJ
97c1f3b27d Fix the Chinese translation switching issue 2026-03-07 22:17:53 +08:00
Janez T
cf70aa2bfb Reduce header space and tweak chat 2026-03-07 14:53:22 +01:00
ydev83
fd7d6afb70 Merge branch 'dz0ny:main' into main 2026-03-07 23:18:51 +10:00
ydev83
8a4b29b968 Add files via upload 2026-03-07 23:15:15 +10:00
26 changed files with 5804 additions and 972 deletions

View File

@@ -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;

View File

@@ -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 &amp; Rescue operations</string> <string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>

View File

@@ -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>

View File

@@ -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':

File diff suppressed because it is too large Load Diff

685
lib/l10n/app_ru.arb Normal file
View 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": "Запрос голоса"
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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();
} }

View 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();
}
}
}

View File

@@ -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(),
), ),

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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';

View File

@@ -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:

View File

@@ -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

View 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;
}
}

View File

@@ -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'),
), ),
], ],
),
],
),
),
); );
} }
} }

View File

@@ -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,
),
),
],
),
);
}
} }

View File

@@ -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"))

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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'),

View File

@@ -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', () {