Compare commits

..

22 Commits

Author SHA1 Message Date
Zach e53c493e78 update TS 2026-04-23 18:01:35 -07:00
Zach 54e0dae172 Add placeholder for multi-ACKs setting in localization 2026-04-23 17:58:40 -07:00
Zach 066aba7c5d #401 Refactor multi-ACK localization strings and settings UI
- Updated localization files for multiple languages to change the representation of multi-ACK settings from a string with a placeholder to a simple string.
- Removed unnecessary placeholder definitions for multi-ACK in localization files.
- Adjusted the settings screen to replace the slider for multi-ACK with a switch, simplifying the user interface.
- Updated the Podfile.lock to remove the wakelock_plus dependency.
2026-04-23 17:58:15 -07:00
zjs81 6b6a881c7a Merge pull request #388 from zjs81/msg-chars
add byte counted text input
2026-04-20 09:17:00 -07:00
ericz 8ef8a38495 change to prepare Outbound Text Functions. 2026-04-17 18:32:14 -07:00
Enot (ded) Skelly ddcda4ba5a keep multiline editing 2026-04-17 14:07:00 -07:00
ericz b572314ae9 respect smaz encoding in message byte length calculation. 2026-04-15 09:04:08 -07:00
Enot (ded) Skelly e97fb9bd24 add byte counted text input
adds a new widget that counts bytes during entry

configurable limit and shows user both count and limit

provides color feedback

use new widget in chat and channel text entry
2026-04-15 09:04:08 -07:00
zjs81 a4bbeffddc Merge pull request #386 from zjs81/dev_translations
Dev translations
2026-04-14 21:38:52 -07:00
zjs81 37ec8f2f05 Add localization for chat and repeater features in multiple languages
- Added translations for "Send message", "Guest information", and "Guest tools" in Bulgarian, German, Spanish, French, Hungarian, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Slovak, Slovenian, Swedish, Ukrainian, and Chinese.
- Updated the "Clock synchronization after login" feature subtitle in all affected languages.
- Removed untranslated keys from the untranslated.json file as they have now been localized.
2026-04-14 21:38:12 -07:00
zjs81 39cd6d5514 Merge pull request #385 from zjs81/dev
merge dev to main
2026-04-14 21:04:04 -07:00
zjs81 44eb4fad58 Merge pull request #361 from zjs81/unused-plugin
remove unused macos path_provider_foundation
2026-04-14 21:02:30 -07:00
zjs81 1a209cbcfc Merge pull request #372 from zjs81/group-elem
fix: settings dialog lists
2026-04-14 21:02:08 -07:00
zjs81 33a8f34463 Merge pull request #365 from zjs81/rpt-guest
enh: make repeater admin guest aware
2026-04-14 20:44:14 -07:00
zjs81 ce8e8f0d5b Merge pull request #384 from zjs81/clear_toast
clear toast on tap
2026-04-14 20:42:23 -07:00
Enot (ded) Skelly aa2d0f1927 clear toast on tap
this adds a generator showDismissibleSnackBar which by default allows
tapping to clear snack bar toasts. all SnackBar properties are still
available and the

all callers should now use showDismissibleSnackBar() instead of calling
ScaffoldMessenger.of(context).showSnackBar(SnackBar())
2026-04-14 12:01:42 -07:00
Ded 0757c8e53a Merge pull request #369 from just-stuff-tm/auto-time-sync-349
add auto clock synchronization setting after repeater login
2026-04-13 08:20:01 -07:00
Enot (ded) Skelly add4731d05 fix: settings dialog lists
switched to using RadioListTile instead of ListTile to be more accessible
2026-04-10 15:11:44 -07:00
Enot (ded) Skelly 7dc162d968 temp
translations fix
2026-04-10 14:15:14 -07:00
just-stuff-tm 8ba4bbfbc5 add auto clock synchronization setting after repeater login
Introduced a new setting for automatic clock synchronization after a successful repeater login.
Added localization support for the new feature in multiple languages (Bulgarian, German, English, Spanish, French, Hungarian, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Slovak, Slovenian, Swedish, Ukrainian, Chinese).
Implemented storage service methods to manage the new setting.
Updated the repeater settings screen to include a toggle for the new feature.
Enhanced the repeater login dialog to trigger clock synchronization automatically if the setting is enabled.
2026-04-10 14:25:53 -04:00
Ded cac6abfef1 Fix dev
rebase dev over main and resolve merge conflicts
2026-04-09 10:12:47 -07:00
Enot (ded) Skelly bdd7fc0cdd remove unused macos path_provider_foundation
added in #299 but appears not needed, flutter removes when building
2026-04-08 14:56:34 -07:00
76 changed files with 2132 additions and 2426 deletions
+11 -77
View File
@@ -40,7 +40,6 @@ import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart';
import '../storage/message_store.dart';
import '../storage/unread_store.dart';
import '../storage/last_device_store.dart';
import '../utils/app_logger.dart';
import '../utils/battery_utils.dart';
import '../utils/platform_info.dart';
@@ -282,7 +281,6 @@ class MeshCoreConnector extends ChangeNotifier {
final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore();
final ChannelStore _channelStore = ChannelStore();
final UnreadStore _unreadStore = UnreadStore();
final LastDeviceStore _lastDeviceStore = LastDeviceStore();
List<Channel> _cachedChannels = [];
final Map<int, bool> _channelSmazEnabled = {};
bool _lastSentWasCliCommand =
@@ -770,10 +768,6 @@ class MeshCoreConnector extends ChangeNotifier {
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
_timeoutPredictionService = timeoutPredictionService;
// When the app resumes from background, check if we need to reconnect.
_backgroundService?.onResume = _onAppResumed;
_usbManager.setDebugLogService(_appDebugLogService);
_tcpConnector.setDebugLogService(_appDebugLogService);
@@ -1885,7 +1879,6 @@ class MeshCoreConnector extends ChangeNotifier {
);
_setState(MeshCoreConnectionState.connected);
_lastDeviceStore.persistLastDevice(_deviceId!, _deviceDisplayName!);
if (_shouldGateInitialChannelSync) {
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = true;
@@ -2232,56 +2225,6 @@ class MeshCoreConnector extends ChangeNotifier {
});
}
/// Called by [BackgroundService] when the app returns to the foreground.
/// If the BLE connection was lost while backgrounded, this kicks off an
/// immediate reconnect attempt instead of waiting for the next timer tick.
void _onAppResumed() {
if (_shouldAutoReconnect &&
_state != MeshCoreConnectionState.connected &&
_state != MeshCoreConnectionState.connecting) {
_appDebugLogService?.info(
'App resumed triggering reconnect check',
tag: 'Lifecycle',
);
_cancelReconnectTimer();
_scheduleReconnect();
} else if (_state == MeshCoreConnectionState.disconnected &&
_lastDeviceId == null) {
// App was fully restarted (swiped away). Try to restore from prefs.
tryAutoReconnect();
}
}
/// Attempt to reconnect to the last persisted BLE device.
///
/// Called on fresh app start (after a swipe-away kill) so the user is
/// brought straight back to the connected state instead of the scan screen.
Future<bool> tryAutoReconnect() async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
return false;
}
final deviceId = _lastDeviceStore.getPersistedDeviceId();
if (deviceId!.isEmpty) {
return false;
}
final displayName = _lastDeviceStore.getPersistedDeviceName();
_appDebugLogService?.info(
'Auto-reconnecting to $deviceId ($displayName)',
tag: 'Lifecycle',
);
try {
final device = BluetoothDevice.fromId(deviceId);
await connect(device, displayName: displayName);
return true;
} catch (e) {
_appDebugLogService?.error('Auto-reconnect failed: $e', tag: 'Lifecycle');
return false;
}
}
Future<void> disconnect({
bool manual = true,
bool skipBleDeviceDisconnect = false,
@@ -2302,8 +2245,6 @@ class MeshCoreConnector extends ChangeNotifier {
if (manual) {
_manualDisconnect = true;
_cancelReconnectTimer();
_lastDeviceStore.clearPersistedDevice();
_notificationService.cancelAll();
unawaited(_backgroundService?.stop());
} else {
_manualDisconnect = false;
@@ -3053,13 +2994,7 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingChannelSentQueue.add(message.messageId);
notifyListeners();
final trimmed = text.trim();
final isStructuredPayload =
trimmed.startsWith('g:') || trimmed.startsWith('m:');
final outboundText =
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text)
: text;
final outboundText = prepareChannelOutboundText(channel.index, text);
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, outboundText),
@@ -4511,6 +4446,16 @@ class MeshCoreConnector extends ChangeNotifier {
return text;
}
String prepareChannelOutboundText(int channelIndex, String text) {
final trimmed = text.trim();
final isStructuredPayload =
trimmed.startsWith('g:') || trimmed.startsWith('m:');
if (!isStructuredPayload && isChannelSmazEnabled(channelIndex)) {
return Smaz.encodeIfSmaller(text);
}
return text;
}
String _channelDisplayName(int channelIndex) {
for (final channel in _channels) {
if (channel.index != channelIndex) continue;
@@ -4969,17 +4914,6 @@ class MeshCoreConnector extends ChangeNotifier {
);
}
/// Public accessor to find a channel by its index.
Channel? findChannelByIndex(int index) => _findChannelByIndex(index);
/// Find a contact by its public key hex string.
Contact? findContactByKeyHex(String keyHex) {
return _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == keyHex,
orElse: () => null,
);
}
void _maybeIncrementChannelUnread(
ChannelMessage message, {
required bool isNew,
+5 -6
View File
@@ -3,6 +3,7 @@ import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/l10n.dart';
import '../utils/platform_info.dart';
import '../helpers/snack_bar_builder.dart';
class LinkHandler {
static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) {
@@ -93,21 +94,19 @@ class LinkHandler {
final uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_couldNotOpenLink(url)),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_invalidLink),
backgroundColor: Colors.red,
),
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
// showDismissibleSnackBar shows a [SnackBar] with tap to dismiss
// all other properties are default and optional
void showDismissibleSnackBar(
BuildContext context, {
Key? key,
required Widget content,
Color? backgroundColor,
double? elevation,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? padding,
double? width,
ShapeBorder? shape,
HitTestBehavior? hitTestBehavior,
SnackBarBehavior? behavior,
SnackBarAction? action,
double? actionOverflowThreshold,
bool? showCloseIcon,
Color? closeIconColor,
Duration? duration,
bool? persist,
Animation<double>? animation,
void Function()? onVisible,
DismissDirection? dismissDirection,
Clip? clipBehavior,
}) {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(
key: key,
content: GestureDetector(
onTap: () => messenger.hideCurrentSnackBar(),
child: content,
),
backgroundColor: backgroundColor,
elevation: elevation,
margin: margin,
padding: padding,
width: width,
shape: shape,
hitTestBehavior: hitTestBehavior,
behavior: behavior,
action: action,
actionOverflowThreshold: actionOverflowThreshold,
showCloseIcon: showCloseIcon,
closeIconColor: closeIconColor,
duration: duration ?? const Duration(seconds: 4),
persist: persist,
animation: animation,
onVisible: onVisible,
dismissDirection: dismissDirection ?? DismissDirection.down,
clipBehavior: clipBehavior ?? Clip.hardEdge,
),
);
}
+16 -3
View File
@@ -4,8 +4,14 @@ import 'package:flutter/services.dart';
class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
final int maxBytes;
final String Function(String)? encoder;
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
const Utf8LengthLimitingTextInputFormatter(this.maxBytes, {this.encoder});
int _effectiveByteLength(String text) {
final effective = encoder != null ? encoder!(text) : text;
return utf8.encode(effective).length;
}
@override
TextEditingValue formatEditUpdate(
@@ -13,8 +19,7 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
TextEditingValue newValue,
) {
if (maxBytes <= 0) return oldValue;
final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue;
if (_effectiveByteLength(newValue.text) <= maxBytes) return newValue;
final truncated = _truncateToMaxBytes(newValue.text, maxBytes);
return TextEditingValue(
@@ -25,6 +30,14 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
}
String _truncateToMaxBytes(String text, int limit) {
if (encoder != null) {
final runes = text.runes.toList();
while (runes.isNotEmpty &&
_effectiveByteLength(String.fromCharCodes(runes)) > maxBytes) {
runes.removeLast();
}
return String.fromCharCodes(runes);
}
final buffer = StringBuffer();
var used = 0;
for (final rune in text.runes) {
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_teleLocSubtitle": "Позволи споделяне на данни за местоположение",
"contact_teleLoc": "Местоположение на телеметрията",
"contact_teleEnvSubtitle": "Позволи споделяне на данни от средносферните датчици",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Първоначална тежест на маршрута",
"appSettings_maxRouteWeight": "Максимално допустимо тегло на маршрута",
"appSettings_initialRouteWeightSubtitle": "Начално тегло за новооткрити маршрути",
@@ -1940,7 +1933,6 @@
"appSettings_maxMessageRetries": "Максимален брой опити за изпращане на съобщение",
"appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "Мулти-потвърди: {value}",
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен",
"map_showOverlaps": "Покриване на ключа на повтаряча",
"map_runTraceWithReturnPath": "Върни се по същия път.",
@@ -2061,5 +2053,18 @@
"scanner_linuxPairingHidePin": "Скриване на PIN кода",
"scanner_linuxPairingShowPin": "Покажи PIN",
"repeater_cliQuickClockSync": "Синхронизация на часовника",
"repeater_cliQuickDiscovery": "Открий Съседи"
"repeater_cliQuickDiscovery": "Открий Съседи",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Автоматично изпращайте съобщение \"синхронизиране на часовника\" след успешно влизане.",
"repeater_clockSyncAfterLogin": "Синхронизиране на часовника след влизане",
"chat_sendMessage": "Изпратете съобщение",
"room_guest": "Информация за сървъра на стаята",
"repeater_guest": "Информация за ретранслаторите",
"repeater_guestTools": "Инструменти за гости",
"settings_multiAck": "Множество потвърждения"
}
+14 -9
View File
@@ -1950,13 +1950,6 @@
"contact_lastSeen": "Zuletzt gesehen",
"contact_clearChat": "Chat löschen",
"contact_teleEnvSubtitle": "Teilen von Umgebungsensordaten zulassen",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeightSubtitle": "Ausgangsgewicht für neu entdeckte Pfade",
"appSettings_maxRouteWeightSubtitle": "Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.",
"appSettings_maxRouteWeight": "Maximale Gesamtstreckenlänge",
@@ -1969,7 +1962,6 @@
"appSettings_maxMessageRetriesSubtitle": "Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Telemetriemodus aktualisiert",
"settings_multiAck": "Mehrfach-Bestätigungen: {value}",
"map_showOverlaps": "Überlappungen der Repeater-Taste",
"map_runTraceWithReturnPath": "Auf dem gleichen Pfad zurückkehren.",
"@radioStats_noiseFloor": {
@@ -2089,5 +2081,18 @@
"scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN",
"scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine).",
"repeater_cliQuickClockSync": "Uhr Synchronisieren",
"repeater_cliQuickDiscovery": "Entdecke Nachbarn"
"repeater_cliQuickDiscovery": "Entdecke Nachbarn",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "Uhrzeit-Synchronisation nach dem Anmelden",
"repeater_clockSyncAfterLoginSubtitle": "Automatisch \"Uhrzeit-Synchronisierung\" nach erfolgreicher Anmeldung senden.",
"repeater_guest": "Informationen zu Repeatern",
"repeater_guestTools": "Gastwerkzeuge",
"chat_sendMessage": "Nachricht senden",
"room_guest": "Informationen zum Room Server",
"settings_multiAck": "Mehrere Bestätigungen"
}
+14 -10
View File
@@ -178,14 +178,7 @@
"settings_telemetryEnvironmentMode": "Telemetry Environment Mode",
"settings_advertLocation": "Advert Location",
"settings_advertLocationSubtitle": "Include location in advert.",
"settings_multiAck": "Multi-ACKs: {value}",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_multiAck": "Multi-ACKs",
"settings_telemetryModeUpdated": "Telemetry mode updated",
"settings_actions": "Actions",
"settings_sendAdvertisement": "Send Advertisement",
@@ -1038,8 +1031,8 @@
"login_enterPassword": "Enter password",
"login_savePassword": "Save password",
"login_savePasswordSubtitle": "Password will be stored securely on this device",
"login_repeaterDescription": "Enter the repeater password to access settings and status.",
"login_roomDescription": "Enter the room password to access settings and status.",
"login_repeaterDescription": "Enter the repeater password for guest or admin access.",
"login_roomDescription": "Enter the room password for guest or admin access.",
"login_routing": "Routing",
"login_routingMode": "Routing mode",
"login_autoUseSavedPath": "Auto (use saved path)",
@@ -1105,7 +1098,10 @@
"path_setPath": "Set Path",
"repeater_management": "Repeater Management",
"room_management": "Room Server Management",
"repeater_guest": "Repeater Information",
"room_guest": "Room Server Information",
"repeater_managementTools": "Management Tools",
"repeater_guestTools": "Guest Tools",
"repeater_status": "Status",
"repeater_statusSubtitle": "View repeater status, stats, and neighbors",
"repeater_telemetry": "Telemetry",
@@ -1116,6 +1112,14 @@
"repeater_neighborsSubtitle": "View zero hop neighbors.",
"repeater_settings": "Settings",
"repeater_settingsSubtitle": "Configure repeater parameters",
"repeater_clockSyncAfterLogin": "Clock sync after login",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"repeater_clockSyncAfterLoginSubtitle": "Automatically send \"clock sync\" after a successful login",
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_statusTitle": "Repeater Status",
"repeater_routingMode": "Routing mode",
"repeater_autoUseSavedPath": "Auto (use saved path)",
+14 -9
View File
@@ -1950,13 +1950,6 @@
"contact_teleBaseSubtitle": "Permitir el intercambio de nivel de batería y telemetría básica",
"contact_teleEnv": "Entorno de Telemetría",
"contact_teleEnvSubtitle": "Permitir el intercambio de datos de sensores de entorno",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Peso inicial de la ruta",
"appSettings_maxRouteWeight": "Peso máximo permitido para la ruta",
"appSettings_initialRouteWeightSubtitle": "Peso inicial para rutas recién descubiertas",
@@ -1969,7 +1962,6 @@
"appSettings_maxMessageRetriesSubtitle": "Número de intentos de reintento antes de marcar un mensaje como fallido.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Modo de telemetría actualizado",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Superposiciones de tecla repetidora",
"map_runTraceWithReturnPath": "Volver atrás por el mismo camino.",
"@radioStats_noiseFloor": {
@@ -2089,5 +2081,18 @@
"translation_translationOptions": "Opciones de traducción",
"translation_systemLanguage": "Idioma del sistema",
"repeater_cliQuickDiscovery": "Descubrir Vecinos",
"repeater_cliQuickClockSync": "Sincronización del reloj"
"repeater_cliQuickClockSync": "Sincronización del reloj",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Enviar automáticamente la función de \"sincronización de reloj\" después de un inicio de sesión exitoso.",
"repeater_clockSyncAfterLogin": "Sincronización del reloj después de iniciar sesión",
"repeater_guest": "Información sobre repetidores",
"chat_sendMessage": "Enviar mensaje",
"repeater_guestTools": "Herramientas para invitados",
"room_guest": "Información del servidor",
"settings_multiAck": "Múltiples respuestas de confirmación"
}
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_lastSeen": "Dernière fois vu",
"contact_clearChat": "Effacer la conversation",
"contact_teleBaseSubtitle": "Autoriser le partage du niveau de batterie et de la télémétrie de base",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Poids maximal qu'un itinéraire peut accumuler grâce à des livraisons réussies.",
"appSettings_initialRouteWeight": "Poids initial de l'itinéraire",
"appSettings_maxRouteWeight": "Poids maximal autorisé pour le trajet",
@@ -1940,7 +1933,6 @@
"appSettings_maxMessageRetries": "Nombre maximal de tentatives de récupération de messages",
"appSettings_maxMessageRetriesSubtitle": "Nombre de tentatives de relance avant de marquer un message comme ayant échoué.",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "Multi-ACKs : {value}",
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour",
"map_showOverlaps": "Chevauchement de la touche répétitive",
"map_runTraceWithReturnPath": "Revenir sur le même chemin.",
@@ -2061,5 +2053,18 @@
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si nécessaire).",
"scanner_linuxPairingShowPin": "Afficher le code PIN",
"repeater_cliQuickClockSync": "Synchronisation de l'horloge",
"repeater_cliQuickDiscovery": "Découvrir les voisins"
"repeater_cliQuickDiscovery": "Découvrir les voisins",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Envoyer automatiquement une notification \"synchronisation de l'heure\" après une connexion réussie.",
"repeater_clockSyncAfterLogin": "Synchronisation de l'horloge après la connexion",
"repeater_guestTools": "Outils pour les invités",
"chat_sendMessage": "Envoyer un message",
"room_guest": "Informations sur le serveur",
"repeater_guest": "Informations sur les répéteurs",
"settings_multiAck": "Plusieurs accusés de réception"
}
+15 -11
View File
@@ -2012,13 +2012,6 @@
"radioStats_stripWaiting": "Rádió adatok begyűjtése…",
"radioStats_settingsTile": "Rádió statisztikák",
"radioStats_settingsSubtitle": "Háttérzaj, RSSI, zaj-sűrűség, és a használat időtartama",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_denyAll": "Elutasítom",
"settings_privacySettingsDescription": "Válassza ki, hogy az eszközének melyik információkat oszt meg másokkal.",
"settings_privacySubtitle": "Ellenőrizd, hogy milyen információkat osztanak meg.",
@@ -2030,7 +2023,6 @@
"settings_telemetryEnvironmentMode": "Adatkapcsolati környezeti mód",
"settings_advertLocation": "Reklám megjelenési hely",
"settings_advertLocationSubtitle": "A hirdetés tartalmazza a helyszínt.",
"settings_multiAck": "Többszöri visszaigazolások: {value}",
"settings_telemetryModeUpdated": "A telemetriamód frissítve",
"contact_info": "Kapcsolattartási információk",
"contact_settings": "Kapcsolat beállítások",
@@ -2081,7 +2073,7 @@
}
},
"scanner_linuxPairingShowPin": "Megjelenítse a PIN-kódot",
"scanner_linuxPairingPinPrompt": "Adja meg a PIN kódot a {deviceName} számára (hagyja üresen, ha nincs).",
"scanner_linuxPairingPinPrompt": "Adja meg a(z) {deviceName} PIN-kódját (hagyja üresen, ha nincs).",
"scanner_linuxPairingHidePin": "Rejtse el a PIN-kódot",
"scanner_linuxPairingPinTitle": "Bluetooth párosítási PIN",
"@translation_translateTo": {
@@ -2098,7 +2090,19 @@
"translation_translateTo": "Fordítás {language}-ra",
"translation_translationOptions": "Fordítási lehetőségek",
"translation_systemLanguage": "Rendszer nyelvé",
"scanner_linuxPairingPinPrompt": "Adja meg a(z) {deviceName} PIN-kódját (hagyja üresen, ha nincs).",
"repeater_cliQuickClockSync": "Óra szinkronizálás",
"repeater_cliQuickDiscovery": "Fedezd fel a szomszédokat"
"repeater_cliQuickDiscovery": "Fedezd fel a szomszédokat",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Automatikusan küldje el a \"óra szinkronizálás\" üzenetet a sikeres bejelentkezés után.",
"repeater_clockSyncAfterLogin": "Óra szinkronizálás bejelentkezés után",
"repeater_guestTools": "Vendégek számára elérhető eszközök",
"room_guest": "Szoba szerver információk",
"chat_sendMessage": "Üzenet küldése",
"repeater_guest": "Adatok a repeaterről",
"settings_multiAck": "Többszörös visszaigazolások"
}
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_teleBaseSubtitle": "Consenti la condivisione del livello della batteria e della telemetria di base",
"contact_teleEnvSubtitle": "Consenti la condivisione dei dati del sensore ambientale",
"contact_teleEnv": "Ambiente di telemetria",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Peso iniziale del percorso",
"appSettings_initialRouteWeightSubtitle": "Peso di partenza per nuovi percorsi",
"appSettings_maxRouteWeightSubtitle": "Il peso massimo che un percorso può accumulare grazie a consegne di successo.",
@@ -1941,7 +1934,6 @@
"appSettings_maxMessageRetriesSubtitle": "Numero di tentativi di riprova prima di considerare un messaggio come fallito.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Modalità telemetria aggiornata",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Sovrapposizioni della chiave ripetitore",
"map_runTraceWithReturnPath": "Tornare indietro sullo stesso percorso",
"@radioStats_noiseFloor": {
@@ -2061,5 +2053,18 @@
"scanner_linuxPairingPinTitle": "PIN per l'accoppiamento Bluetooth",
"scanner_linuxPairingHidePin": "Nascondi il PIN",
"repeater_cliQuickClockSync": "Sincronizzazione dell'orologio",
"repeater_cliQuickDiscovery": "Scopri i Vicini"
"repeater_cliQuickDiscovery": "Scopri i Vicini",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Invia automaticamente il comando \"sincronizzazione dell'orologio\" dopo un login riuscito.",
"repeater_clockSyncAfterLogin": "Sincronizzazione dell'orologio dopo il login",
"repeater_guest": "Informazioni sul ripetitore",
"repeater_guestTools": "Strumenti per gli ospiti",
"chat_sendMessage": "Invia messaggio",
"room_guest": "Informazioni sul server",
"settings_multiAck": "ACK multipli"
}
+14 -9
View File
@@ -2012,13 +2012,6 @@
"radioStats_stripWaiting": "ラジオの統計情報を取得中…",
"radioStats_settingsTile": "ラジオの統計",
"radioStats_settingsSubtitle": "ノイズレベル、RSSI、SNR、および通信時間",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_privacy": "プライバシー設定",
"settings_privacySubtitle": "共有する情報の内容を管理する。",
"settings_denyAll": "すべてを否定",
@@ -2030,7 +2023,6 @@
"settings_telemetryEnvironmentMode": "テレメトリ環境モード",
"settings_advertLocation": "広告掲載場所",
"settings_advertLocationSubtitle": "広告に場所を記載してください。",
"settings_multiAck": "複数のACK{value}",
"settings_telemetryModeUpdated": "テレメトリモードが更新されました",
"contact_info": "連絡先",
"contact_settings": "連絡設定",
@@ -2099,5 +2091,18 @@
"scanner_linuxPairingPinTitle": "Bluetooth ペアリング PIN",
"scanner_linuxPairingPinPrompt": "{deviceName}のPINを入力してください(なしの場合は空欄のまま)。",
"repeater_cliQuickClockSync": "クロック同期",
"repeater_cliQuickDiscovery": "近隣を発見する"
"repeater_cliQuickDiscovery": "近隣を発見する",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "ログイン後、時計の時刻を同期する",
"repeater_clockSyncAfterLoginSubtitle": "ログインが成功した場合、自動的に「時刻同期」を送信する。",
"room_guest": "ルームサーバーに関する情報",
"chat_sendMessage": "メッセージを送信する",
"repeater_guest": "繰り返し送信に関する情報",
"repeater_guestTools": "ゲスト向けツール",
"settings_multiAck": "複数のACK(応答)"
}
+14 -9
View File
@@ -2012,13 +2012,6 @@
"radioStats_stripWaiting": "라디오 통계 가져오기…",
"radioStats_settingsTile": "라디오 통계",
"radioStats_settingsSubtitle": "잡음 수준, RSSI, 신호 대 잡음비, 통신 시간",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_privacy": "개인 정보 설정",
"settings_privacySubtitle": "어떤 정보를 공유할지 통제하세요.",
"settings_privacySettingsDescription": "어떤 정보를 기기가 다른 사람들과 공유할지 선택하세요.",
@@ -2030,7 +2023,6 @@
"settings_telemetryEnvironmentMode": "텔레메트리 환경 모드",
"settings_advertLocation": "광고 위치",
"settings_advertLocationSubtitle": "광고에 위치 정보를 포함하세요.",
"settings_multiAck": "다중 ACK: {value}",
"settings_telemetryModeUpdated": "텔레메트리 모드 업데이트 완료",
"contact_info": "연락처",
"contact_settings": "연락처 설정",
@@ -2099,5 +2091,18 @@
"translation_translationOptions": "번역 옵션",
"translation_systemLanguage": "시스템 언어",
"repeater_cliQuickClockSync": "시계 동기화",
"repeater_cliQuickDiscovery": "이웃 발견하기"
"repeater_cliQuickDiscovery": "이웃 발견하기",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "로그인 후 시계 동기화",
"repeater_clockSyncAfterLoginSubtitle": "성공적인 로그인 후, 자동으로 \"시간 동기화\"를 전송합니다.",
"repeater_guestTools": "손님용 도구",
"chat_sendMessage": "메시지를 보내기",
"repeater_guest": "반복 장비 정보",
"room_guest": "서버 정보",
"settings_multiAck": "다중 ACK"
}
+34 -4
View File
@@ -901,8 +901,8 @@ abstract class AppLocalizations {
/// No description provided for @settings_multiAck.
///
/// In en, this message translates to:
/// **'Multi-ACKs: {value}'**
String settings_multiAck(String value);
/// **'Multi-ACKs'**
String get settings_multiAck;
/// No description provided for @settings_telemetryModeUpdated.
///
@@ -3438,13 +3438,13 @@ abstract class AppLocalizations {
/// No description provided for @login_repeaterDescription.
///
/// In en, this message translates to:
/// **'Enter the repeater password to access settings and status.'**
/// **'Enter the repeater password for guest or admin access.'**
String get login_repeaterDescription;
/// No description provided for @login_roomDescription.
///
/// In en, this message translates to:
/// **'Enter the room password to access settings and status.'**
/// **'Enter the room password for guest or admin access.'**
String get login_roomDescription;
/// No description provided for @login_routing.
@@ -3609,12 +3609,30 @@ abstract class AppLocalizations {
/// **'Room Server Management'**
String get room_management;
/// No description provided for @repeater_guest.
///
/// In en, this message translates to:
/// **'Repeater Information'**
String get repeater_guest;
/// No description provided for @room_guest.
///
/// In en, this message translates to:
/// **'Room Server Information'**
String get room_guest;
/// No description provided for @repeater_managementTools.
///
/// In en, this message translates to:
/// **'Management Tools'**
String get repeater_managementTools;
/// No description provided for @repeater_guestTools.
///
/// In en, this message translates to:
/// **'Guest Tools'**
String get repeater_guestTools;
/// No description provided for @repeater_status.
///
/// In en, this message translates to:
@@ -3675,6 +3693,18 @@ abstract class AppLocalizations {
/// **'Configure repeater parameters'**
String get repeater_settingsSubtitle;
/// Repeater setting: auto sync device clock after successful login
///
/// In en, this message translates to:
/// **'Clock sync after login'**
String get repeater_clockSyncAfterLogin;
/// Repeater setting subtitle: describes the clock sync after login behavior
///
/// In en, this message translates to:
/// **'Automatically send \"clock sync\" after a successful login'**
String get repeater_clockSyncAfterLoginSubtitle;
/// No description provided for @repeater_statusTitle.
///
/// In en, this message translates to:
+19 -4
View File
@@ -437,9 +437,7 @@ class AppLocalizationsBg extends AppLocalizations {
'Включи местоположение в обявата';
@override
String settings_multiAck(String value) {
return 'Мулти-потвърди: $value';
}
String get settings_multiAck => 'Множество потвърждения';
@override
String get settings_telemetryModeUpdated => 'Режим на телеметрията е обновен';
@@ -1240,7 +1238,7 @@ class AppLocalizationsBg extends AppLocalizations {
String get chat_noMessages => 'Няма съобщения.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Изпратете съобщение';
@override
String chat_sendMessageTo(String contactName) {
@@ -2019,9 +2017,18 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get room_management => 'Управление на сървъра за стая';
@override
String get repeater_guest => 'Информация за ретранслаторите';
@override
String get room_guest => 'Информация за сървъра на стаята';
@override
String get repeater_managementTools => 'Инструменти за управление';
@override
String get repeater_guestTools => 'Инструменти за гости';
@override
String get repeater_status => 'Статус';
@@ -2056,6 +2063,14 @@ class AppLocalizationsBg extends AppLocalizations {
String get repeater_settingsSubtitle =>
'Конфигурирайте параметрите на репитера';
@override
String get repeater_clockSyncAfterLogin =>
'Синхронизиране на часовника след влизане';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Автоматично изпращайте съобщение \"синхронизиране на часовника\" след успешно влизане.';
@override
String get repeater_statusTitle => 'Статус на повтарянето';
+19 -4
View File
@@ -435,9 +435,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Ort in der Anzeige einbeziehen';
@override
String settings_multiAck(String value) {
return 'Mehrfach-Bestätigungen: $value';
}
String get settings_multiAck => 'Mehrere Bestätigungen';
@override
String get settings_telemetryModeUpdated => 'Telemetriemodus aktualisiert';
@@ -1239,7 +1237,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get chat_noMessages => 'Noch keine Nachrichten.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Nachricht senden';
@override
String chat_sendMessageTo(String contactName) {
@@ -2017,9 +2015,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get room_management => 'Raum-Server-Verwaltung';
@override
String get repeater_guest => 'Informationen zu Repeatern';
@override
String get room_guest => 'Informationen zum Room Server';
@override
String get repeater_managementTools => 'Verwaltungs-Tools';
@override
String get repeater_guestTools => 'Gastwerkzeuge';
@override
String get repeater_status => 'Status';
@@ -2052,6 +2059,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Repeater-parameter konfigurieren';
@override
String get repeater_clockSyncAfterLogin =>
'Uhrzeit-Synchronisation nach dem Anmelden';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Automatisch \"Uhrzeit-Synchronisierung\" nach erfolgreicher Anmeldung senden.';
@override
String get repeater_statusTitle => 'Repeaterstatus';
+19 -5
View File
@@ -427,9 +427,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get settings_advertLocationSubtitle => 'Include location in advert.';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
String get settings_multiAck => 'Multi-ACKs';
@override
String get settings_telemetryModeUpdated => 'Telemetry mode updated';
@@ -1871,11 +1869,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get login_repeaterDescription =>
'Enter the repeater password to access settings and status.';
'Enter the repeater password for guest or admin access.';
@override
String get login_roomDescription =>
'Enter the room password to access settings and status.';
'Enter the room password for guest or admin access.';
@override
String get login_routing => 'Routing';
@@ -1979,9 +1977,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get room_management => 'Room Server Management';
@override
String get repeater_guest => 'Repeater Information';
@override
String get room_guest => 'Room Server Information';
@override
String get repeater_managementTools => 'Management Tools';
@override
String get repeater_guestTools => 'Guest Tools';
@override
String get repeater_status => 'Status';
@@ -2014,6 +2021,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Configure repeater parameters';
@override
String get repeater_clockSyncAfterLogin => 'Clock sync after login';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Automatically send \"clock sync\" after a successful login';
@override
String get repeater_statusTitle => 'Repeater Status';
+19 -4
View File
@@ -434,9 +434,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get settings_advertLocationSubtitle => 'Incluir ubicación en anuncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
String get settings_multiAck => 'Múltiples respuestas de confirmación';
@override
String get settings_telemetryModeUpdated => 'Modo de telemetría actualizado';
@@ -1239,7 +1237,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get chat_noMessages => 'Aún no hay mensajes';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Enviar mensaje';
@override
String chat_sendMessageTo(String contactName) {
@@ -2015,9 +2013,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get room_management => 'Administración del Servidor de Habitación';
@override
String get repeater_guest => 'Información sobre repetidores';
@override
String get room_guest => 'Información del servidor';
@override
String get repeater_managementTools => 'Herramientas de Gestión';
@override
String get repeater_guestTools => 'Herramientas para invitados';
@override
String get repeater_status => 'Estado';
@@ -2050,6 +2057,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Configurar parámetros del repetidor';
@override
String get repeater_clockSyncAfterLogin =>
'Sincronización del reloj después de iniciar sesión';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Enviar automáticamente la función de \"sincronización de reloj\" después de un inicio de sesión exitoso.';
@override
String get repeater_statusTitle => 'Estado del Repetidor';
+19 -4
View File
@@ -438,9 +438,7 @@ class AppLocalizationsFr extends AppLocalizations {
'Inclure l\'emplacement dans l\'annonce';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs : $value';
}
String get settings_multiAck => 'Plusieurs accusés de réception';
@override
String get settings_telemetryModeUpdated =>
@@ -1244,7 +1242,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get chat_noMessages => 'Aucun message pour le moment.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Envoyer un message';
@override
String chat_sendMessageTo(String contactName) {
@@ -2026,9 +2024,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get room_management => 'Administrattion Room Server';
@override
String get repeater_guest => 'Informations sur les répéteurs';
@override
String get room_guest => 'Informations sur le serveur';
@override
String get repeater_managementTools => 'Outils de Gestion';
@override
String get repeater_guestTools => 'Outils pour les invités';
@override
String get repeater_status => 'État';
@@ -2062,6 +2069,14 @@ class AppLocalizationsFr extends AppLocalizations {
String get repeater_settingsSubtitle =>
'Configurer les paramètres du répéteur';
@override
String get repeater_clockSyncAfterLogin =>
'Synchronisation de l\'horloge après la connexion';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Envoyer automatiquement une notification \"synchronisation de l\'heure\" après une connexion réussie.';
@override
String get repeater_statusTitle => 'État du répéteur';
+19 -4
View File
@@ -437,9 +437,7 @@ class AppLocalizationsHu extends AppLocalizations {
'A hirdetés tartalmazza a helyszínt.';
@override
String settings_multiAck(String value) {
return 'Többszöri visszaigazolások: $value';
}
String get settings_multiAck => 'Többszörös visszaigazolások';
@override
String get settings_telemetryModeUpdated => 'A telemetriamód frissítve';
@@ -1247,7 +1245,7 @@ class AppLocalizationsHu extends AppLocalizations {
String get chat_noMessages => 'Még nincs üzenet.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Üzenet küldése';
@override
String chat_sendMessageTo(String contactName) {
@@ -2030,9 +2028,18 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get room_management => 'Szoba-szerver kezelés';
@override
String get repeater_guest => 'Adatok a repeaterről';
@override
String get room_guest => 'Szoba szerver információk';
@override
String get repeater_managementTools => 'Menedzsmentes eszközök';
@override
String get repeater_guestTools => 'Vendégek számára elérhető eszközök';
@override
String get repeater_status => 'Állapot';
@@ -2066,6 +2073,14 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Állítsa be a repeater paramétereket';
@override
String get repeater_clockSyncAfterLogin =>
'Óra szinkronizálás bejelentkezés után';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Automatikusan küldje el a \"óra szinkronizálás\" üzenetet a sikeres bejelentkezés után.';
@override
String get repeater_statusTitle => 'Adatkapcsolódás állapot';
+19 -4
View File
@@ -437,9 +437,7 @@ class AppLocalizationsIt extends AppLocalizations {
'Includi la posizione nell\'annuncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
String get settings_multiAck => 'ACK multipli';
@override
String get settings_telemetryModeUpdated => 'Modalità telemetria aggiornata';
@@ -1240,7 +1238,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get chat_noMessages => 'Nessun messaggio ancora';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Invia messaggio';
@override
String chat_sendMessageTo(String contactName) {
@@ -2016,9 +2014,18 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get room_management => 'Gestione del Server di Camera';
@override
String get repeater_guest => 'Informazioni sul ripetitore';
@override
String get room_guest => 'Informazioni sul server';
@override
String get repeater_managementTools => 'Strumenti di Gestione';
@override
String get repeater_guestTools => 'Strumenti per gli ospiti';
@override
String get repeater_status => 'Stato';
@@ -2053,6 +2060,14 @@ class AppLocalizationsIt extends AppLocalizations {
String get repeater_settingsSubtitle =>
'Configura i parametri del ripetitore';
@override
String get repeater_clockSyncAfterLogin =>
'Sincronizzazione dell\'orologio dopo il login';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Invia automaticamente il comando \"sincronizzazione dell\'orologio\" dopo un login riuscito.';
@override
String get repeater_statusTitle => 'Stato del Ripetitore';
+18 -4
View File
@@ -414,9 +414,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get settings_advertLocationSubtitle => '広告に場所を記載してください。';
@override
String settings_multiAck(String value) {
return '複数のACK$value';
}
String get settings_multiAck => '複数のACK(応答)';
@override
String get settings_telemetryModeUpdated => 'テレメトリモードが更新されました';
@@ -1180,7 +1178,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get chat_noMessages => 'まだメッセージは届いていません';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'メッセージを送信する';
@override
String chat_sendMessageTo(String contactName) {
@@ -1932,9 +1930,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get room_management => 'ルームサーバーの管理';
@override
String get repeater_guest => '繰り返し送信に関する情報';
@override
String get room_guest => 'ルームサーバーに関する情報';
@override
String get repeater_managementTools => '管理ツール';
@override
String get repeater_guestTools => 'ゲスト向けツール';
@override
String get repeater_status => 'ステータス';
@@ -1965,6 +1972,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'リピーターのパラメータを設定する';
@override
String get repeater_clockSyncAfterLogin => 'ログイン後、時計の時刻を同期する';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'ログインが成功した場合、自動的に「時刻同期」を送信する。';
@override
String get repeater_statusTitle => '再送ステータス';
+18 -4
View File
@@ -414,9 +414,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get settings_advertLocationSubtitle => '광고에 위치 정보를 포함하세요.';
@override
String settings_multiAck(String value) {
return '다중 ACK: $value';
}
String get settings_multiAck => '다중 ACK';
@override
String get settings_telemetryModeUpdated => '텔레메트리 모드 업데이트 완료';
@@ -1175,7 +1173,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get chat_noMessages => '아직 메시지가 없습니다.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => '메시지를 보내기';
@override
String chat_sendMessageTo(String contactName) {
@@ -1929,9 +1927,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get room_management => '방 서버 관리';
@override
String get repeater_guest => '반복 장비 정보';
@override
String get room_guest => '서버 정보';
@override
String get repeater_managementTools => '관리 도구';
@override
String get repeater_guestTools => '손님용 도구';
@override
String get repeater_status => '상태';
@@ -1962,6 +1969,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get repeater_settingsSubtitle => '리피터 파라미터 설정';
@override
String get repeater_clockSyncAfterLogin => '로그인 후 시계 동기화';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'성공적인 로그인 후, 자동으로 \"시간 동기화\"를 전송합니다.';
@override
String get repeater_statusTitle => '반복 장치 상태';
+19 -4
View File
@@ -432,9 +432,7 @@ class AppLocalizationsNl extends AppLocalizations {
'Locatie opnemen in advertentie';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
String get settings_multiAck => 'Meerdere bevestigingen';
@override
String get settings_telemetryModeUpdated => 'Telemetrie-modus bijgewerkt';
@@ -1228,7 +1226,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get chat_noMessages => 'Nog geen berichten.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Verzend bericht';
@override
String chat_sendMessageTo(String contactName) {
@@ -2003,9 +2001,18 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get room_management => 'Beheer Server Kamer';
@override
String get repeater_guest => 'Informatie over herhalingsapparatuur';
@override
String get room_guest => 'Informatie over de server';
@override
String get repeater_managementTools => 'Beheerfuncties';
@override
String get repeater_guestTools => 'Gastenfuncties';
@override
String get repeater_status => 'Status';
@@ -2038,6 +2045,14 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Configureer repeaterparameters';
@override
String get repeater_clockSyncAfterLogin =>
'Na het inloggen, klok synchroniseren';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Automatisch een \"klok synchroniseren\" bericht versturen na een succesvolle inlog.';
@override
String get repeater_statusTitle => 'Status repeater';
+19 -4
View File
@@ -439,9 +439,7 @@ class AppLocalizationsPl extends AppLocalizations {
'Uwzględnij lokalizację w ogłoszeniu';
@override
String settings_multiAck(String value) {
return 'Wielokrotne ACK: $value';
}
String get settings_multiAck => 'Wielokrotne potwierdzenia odbioru';
@override
String get settings_telemetryModeUpdated =>
@@ -1248,7 +1246,7 @@ class AppLocalizationsPl extends AppLocalizations {
String get chat_noMessages => 'Brak jeszcze wiadomości';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Wyślij wiadomość';
@override
String chat_sendMessageTo(String contactName) {
@@ -2031,9 +2029,18 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get room_management => 'Zarządzanie Serwerem Pokoju';
@override
String get repeater_guest => 'Informacje dotyczące urządzenia powtarzającego';
@override
String get room_guest => 'Informacje o serwerze';
@override
String get repeater_managementTools => 'Narzędzia Zarządzania';
@override
String get repeater_guestTools => 'Narzędzia dla gości';
@override
String get repeater_status => 'Status';
@@ -2066,6 +2073,14 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Skonfiguruj parametry przekaźnika';
@override
String get repeater_clockSyncAfterLogin =>
'Synchronizacja zegara po zalogowaniu';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Automatycznie wysyłaj powiadomienie \"synchronizacja zegara\" po pomyślnym zalogowaniu.';
@override
String get repeater_statusTitle => 'Status przekaźnika';
+19 -4
View File
@@ -436,9 +436,7 @@ class AppLocalizationsPt extends AppLocalizations {
'Incluir localização no anúncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
String get settings_multiAck => 'Multi-ACKs';
@override
String get settings_telemetryModeUpdated => 'Modo de telemetria atualizado';
@@ -1239,7 +1237,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get chat_noMessages => 'Ainda não existem mensagens.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Enviar mensagem';
@override
String chat_sendMessageTo(String contactName) {
@@ -2015,9 +2013,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get room_management => 'Gerenciamento de Servidor de Sala';
@override
String get repeater_guest => 'Informações sobre repetidores';
@override
String get room_guest => 'Informações do Servidor';
@override
String get repeater_managementTools => 'Ferramentas de Gerenciamento';
@override
String get repeater_guestTools => 'Ferramentas para hóspedes';
@override
String get repeater_status => 'Status';
@@ -2050,6 +2057,14 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Configurar parâmetros do repetidor';
@override
String get repeater_clockSyncAfterLogin =>
'Sincronização do relógio após o login';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Enviar automaticamente a sincronização do \"relógio\" após um login bem-sucedido.';
@override
String get repeater_statusTitle => 'Status do Repetidor';
+19 -4
View File
@@ -436,9 +436,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Включить местоположение в объявление';
@override
String settings_multiAck(String value) {
return 'Мульти-ACK: $value';
}
String get settings_multiAck => 'Несколько подтверждений';
@override
String get settings_telemetryModeUpdated => 'Режим телеметрии обновлен';
@@ -1239,7 +1237,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get chat_noMessages => 'Сообщений пока нет';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Отправить сообщение';
@override
String chat_sendMessageTo(String contactName) {
@@ -2019,9 +2017,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get room_management => 'Управление сервером комнат';
@override
String get repeater_guest => 'Информация о ретрансляторе';
@override
String get room_guest => 'Информация о сервере';
@override
String get repeater_managementTools => 'Инструменты управления';
@override
String get repeater_guestTools => 'Инструменты для гостей';
@override
String get repeater_status => 'Статус';
@@ -2054,6 +2061,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Настройка параметров репитера';
@override
String get repeater_clockSyncAfterLogin =>
'Синхронизация часов после входа в систему';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Автоматически отправлять сообщение \"синхронизация времени\" после успешной авторизации.';
@override
String get repeater_statusTitle => 'Статус репитера';
+19 -4
View File
@@ -430,9 +430,7 @@ class AppLocalizationsSk extends AppLocalizations {
String get settings_advertLocationSubtitle => 'Zahrnúť polohu do inzerátu';
@override
String settings_multiAck(String value) {
return 'Viaceré ACK: $value';
}
String get settings_multiAck => 'Viaceré ACK';
@override
String get settings_telemetryModeUpdated =>
@@ -1227,7 +1225,7 @@ class AppLocalizationsSk extends AppLocalizations {
String get chat_noMessages => 'Zatiaľ žiadne správy.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Odoslať správu';
@override
String chat_sendMessageTo(String contactName) {
@@ -2004,9 +2002,18 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get room_management => 'Správa servera miestnosti';
@override
String get repeater_guest => 'Informácie o opakovači';
@override
String get room_guest => 'Informácie o serveri';
@override
String get repeater_managementTools => 'Nástroje na správu';
@override
String get repeater_guestTools => 'Nástroje pre hostí';
@override
String get repeater_status => 'Status';
@@ -2039,6 +2046,14 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Konfigurujte parametre opakovača';
@override
String get repeater_clockSyncAfterLogin =>
'Synchronizácia hodiniek po prihlávení';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Automaticky posielajte notifikáciu \"synchronizácia času\" po úspešnom prihládení.';
@override
String get repeater_statusTitle => 'Status opakého zboru';
+18 -4
View File
@@ -430,9 +430,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_advertLocationSubtitle => 'Vključi lokacijo v oglas.';
@override
String settings_multiAck(String value) {
return 'Večkratni potrditvi: $value';
}
String get settings_multiAck => 'Več potrdil';
@override
String get settings_telemetryModeUpdated => 'Način telemetrije posodobljen';
@@ -1225,7 +1223,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get chat_noMessages => 'Še ni sporočil.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Pošlji sporočilo';
@override
String chat_sendMessageTo(String contactName) {
@@ -2001,9 +1999,18 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get room_management => 'Upravljanje stremlišča';
@override
String get repeater_guest => 'Informacije o ponovljalniku';
@override
String get room_guest => 'Informacije o strežniku';
@override
String get repeater_managementTools => 'Upravne orodje';
@override
String get repeater_guestTools => 'Naložila za goste';
@override
String get repeater_status => 'Status';
@@ -2038,6 +2045,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get repeater_settingsSubtitle =>
'Konfigurirajte parametre ponovitelja';
@override
String get repeater_clockSyncAfterLogin => 'Sinhronizacija ure po prijavi';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Samodejno po uspešnem vstopu pošljite obvestilo o sinhronizaciji časa.';
@override
String get repeater_statusTitle => 'Status ponovitelja';
+19 -4
View File
@@ -428,9 +428,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get settings_advertLocationSubtitle => 'Inkludera plats i annonsen';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
String get settings_multiAck => 'Flera bekräftelser';
@override
String get settings_telemetryModeUpdated => 'Telemetri-läge uppdaterat';
@@ -1218,7 +1216,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get chat_noMessages => 'Inga meddelanden ännu';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Skicka meddelande';
@override
String chat_sendMessageTo(String contactName) {
@@ -1990,9 +1988,18 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get room_management => 'Rumserverhantering';
@override
String get repeater_guest => 'Information om repetorer';
@override
String get room_guest => 'Information om servern';
@override
String get repeater_managementTools => 'Administrationsverktyg';
@override
String get repeater_guestTools => 'Gästverktyg';
@override
String get repeater_status => 'Status';
@@ -2025,6 +2032,14 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Konfigurera återspolarparametrar';
@override
String get repeater_clockSyncAfterLogin =>
'Synkronisera klockan efter inloggning';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Automatiskt skicka \"klocksynkronisering\" efter en lyckad inloggning.';
@override
String get repeater_statusTitle => 'Återspelsstatus';
+18 -4
View File
@@ -432,9 +432,7 @@ class AppLocalizationsUk extends AppLocalizations {
'Включити місце розташування в оголошення';
@override
String settings_multiAck(String value) {
return 'Багатократне підтвердження: $value';
}
String get settings_multiAck => 'Багато підтверджень';
@override
String get settings_telemetryModeUpdated => 'Режим телеметрії оновлено';
@@ -1231,7 +1229,7 @@ class AppLocalizationsUk extends AppLocalizations {
String get chat_noMessages => 'Поки немає повідомлень.';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => 'Надіслати повідомлення';
@override
String chat_sendMessageTo(String contactName) {
@@ -2014,9 +2012,18 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get room_management => 'Адміністрування сервера кімнати';
@override
String get repeater_guest => 'Інформація про ретранслятор';
@override
String get room_guest => 'Інформація про сервер кімнати';
@override
String get repeater_managementTools => 'Інструменти керування';
@override
String get repeater_guestTools => 'Інструменти для гостей';
@override
String get repeater_status => 'Статус';
@@ -2050,6 +2057,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get repeater_settingsSubtitle => 'Налаштувати параметри ретранслятора';
@override
String get repeater_clockSyncAfterLogin => 'Синхронізація годин після входу';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Автоматично надсилати повідомлення \"синхронізація годин\" після успішного входу.';
@override
String get repeater_statusTitle => 'Статус ретранслятора';
+17 -4
View File
@@ -408,9 +408,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get settings_advertLocationSubtitle => '在广告中包含位置';
@override
String settings_multiAck(String value) {
return '多重ACK$value';
}
String get settings_multiAck => '多重ACK';
@override
String get settings_telemetryModeUpdated => '遥测模式已更新';
@@ -1162,7 +1160,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get chat_noMessages => '暂无消息';
@override
String get chat_sendMessage => 'Send message';
String get chat_sendMessage => '发送消息';
@override
String chat_sendMessageTo(String contactName) {
@@ -1890,9 +1888,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get room_management => '房间服务器管理';
@override
String get repeater_guest => '重复器信息';
@override
String get room_guest => '服务器信息';
@override
String get repeater_managementTools => '管理工具';
@override
String get repeater_guestTools => '访客工具';
@override
String get repeater_status => '状态';
@@ -1923,6 +1930,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get repeater_settingsSubtitle => '配置转发节点参数';
@override
String get repeater_clockSyncAfterLogin => '登录后,自动同步时钟';
@override
String get repeater_clockSyncAfterLoginSubtitle => '在成功登录后,自动发送“时钟同步”指令。';
@override
String get repeater_statusTitle => '转发节点状态';
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_lastSeen": "Laatst gezien",
"contact_clearChat": "Chat leegmaken",
"contact_teleBaseSubtitle": "Sta delen van batterij niveau en basis telemetrie toe",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.",
"appSettings_initialRouteWeight": "เริ่มต้น gewicht van de route",
"appSettings_maxRouteWeight": "Maximale gewicht voor de route",
@@ -1941,7 +1934,6 @@
"appSettings_maxMessageRetriesSubtitle": "Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Telemetrie-modus bijgewerkt",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Herhalingssleutel overlapt",
"map_runTraceWithReturnPath": "Terugkeren op hetzelfde pad.",
"@radioStats_noiseFloor": {
@@ -2061,5 +2053,18 @@
"scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).",
"scanner_linuxPairingPinTitle": "BluetoothkoppelingsPIN",
"repeater_cliQuickDiscovery": "Ontdek Buren",
"repeater_cliQuickClockSync": "Kloksynchronisatie"
"repeater_cliQuickClockSync": "Kloksynchronisatie",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Automatisch een \"klok synchroniseren\" bericht versturen na een succesvolle inlog.",
"repeater_clockSyncAfterLogin": "Na het inloggen, klok synchroniseren",
"repeater_guestTools": "Gastenfuncties",
"room_guest": "Informatie over de server",
"chat_sendMessage": "Verzend bericht",
"repeater_guest": "Informatie over herhalingsapparatuur",
"settings_multiAck": "Meerdere bevestigingen"
}
+14 -9
View File
@@ -1960,13 +1960,6 @@
"contact_settings": "Ustawienia kontaktowe",
"contact_lastSeen": "Ostatnio widziany",
"contact_teleBaseSubtitle": "Pozwól na udostępnianie poziomu naładowania baterii i podstawowych danych telemetrycznych",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Początkowa waga trasy",
"appSettings_maxRouteWeight": "Maksymalny dopuszczalny ciężar pojazdu",
"appSettings_initialRouteWeightSubtitle": "Początkowa waga dla nowych, odkrytych ścieżek",
@@ -1979,7 +1972,6 @@
"appSettings_maxMessageRetriesSubtitle": "Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Tryb telemetryczny zaktualizowany",
"settings_multiAck": "Wielokrotne ACK: {value}",
"map_showOverlaps": "Nakładające się klucze przekaźników",
"map_runTraceWithReturnPath": "Wróć tą samą ścieżką",
"@radioStats_noiseFloor": {
@@ -2099,5 +2091,18 @@
"scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pozostaw puste, jeśli brak).",
"scanner_linuxPairingPinTitle": "Kod PIN parowania Bluetooth",
"repeater_cliQuickClockSync": "Synchronizacja zegara",
"repeater_cliQuickDiscovery": "Odkryj Sąsiadów"
"repeater_cliQuickDiscovery": "Odkryj Sąsiadów",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "Synchronizacja zegara po zalogowaniu",
"repeater_clockSyncAfterLoginSubtitle": "Automatycznie wysyłaj powiadomienie \"synchronizacja zegara\" po pomyślnym zalogowaniu.",
"chat_sendMessage": "Wyślij wiadomość",
"repeater_guestTools": "Narzędzia dla gości",
"repeater_guest": "Informacje dotyczące urządzenia powtarzającego",
"room_guest": "Informacje o serwerze",
"settings_multiAck": "Wielokrotne potwierdzenia odbioru"
}
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_telemetry": "Telemetria",
"contact_settings": "Configurações de Contato",
"contact_teleBaseSubtitle": "Permitir compartilhamento do nível da bateria e telemetria básica",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Peso Inicial da Rota",
"appSettings_maxRouteWeight": "Peso Máximo da Rota",
"appSettings_maxRouteWeightSubtitle": "Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.",
@@ -1941,7 +1934,7 @@
"appSettings_maxMessageRetriesSubtitle": "Número de tentativas de reenvio antes de classificar uma mensagem como falha.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Modo de telemetria atualizado",
"settings_multiAck": "Multi-ACKs: {value}",
"settings_multiAck": "Multi-ACKs",
"map_showOverlaps": "Sobreposições da Chave Repeater",
"map_runTraceWithReturnPath": "Retornar ao mesmo caminho.",
"@radioStats_noiseFloor": {
@@ -2061,5 +2054,17 @@
"scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).",
"scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth",
"repeater_cliQuickClockSync": "Sincronização do Relógio",
"repeater_cliQuickDiscovery": "Descobrir Vizinhos"
"repeater_cliQuickDiscovery": "Descobrir Vizinhos",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Enviar automaticamente a sincronização do \"relógio\" após um login bem-sucedido.",
"repeater_clockSyncAfterLogin": "Sincronização do relógio após o login",
"room_guest": "Informações do Servidor",
"chat_sendMessage": "Enviar mensagem",
"repeater_guest": "Informações sobre repetidores",
"repeater_guestTools": "Ferramentas para hóspedes"
}
+14 -9
View File
@@ -1162,13 +1162,6 @@
"contact_clearChat": "Очистить чат",
"contact_lastSeen": "Последний раз видели",
"contact_teleBaseSubtitle": "Разрешить обмен уровнем заряда батареи и базовой телеметрией",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута",
"appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.",
"appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов",
@@ -1181,7 +1174,6 @@
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
"settings_multiAck": "Мульти-ACK: {value}",
"map_showOverlaps": "Перекрытия ключа повтора",
"map_runTraceWithReturnPath": "Вернуться обратно по тому же пути",
"@radioStats_noiseFloor": {
@@ -1301,5 +1293,18 @@
"scanner_linuxPairingHidePin": "Скрыть PIN",
"scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth",
"repeater_cliQuickDiscovery": "Обнаружить Соседей",
"repeater_cliQuickClockSync": "Синхронизация часов"
"repeater_cliQuickClockSync": "Синхронизация часов",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "Синхронизация часов после входа в систему",
"repeater_clockSyncAfterLoginSubtitle": "Автоматически отправлять сообщение \"синхронизация времени\" после успешной авторизации.",
"chat_sendMessage": "Отправить сообщение",
"repeater_guest": "Информация о ретрансляторе",
"room_guest": "Информация о сервере",
"repeater_guestTools": "Инструменты для гостей",
"settings_multiAck": "Несколько подтверждений"
}
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_lastSeen": "Naposledy videný",
"contact_teleBase": "Báza telemetrie",
"contact_teleEnvSubtitle": "Povoliť zdieľanie údajov senzorov prostredia",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.",
"appSettings_initialRouteWeightSubtitle": "Počiatočná váha pre nové, objavené cesty",
"appSettings_initialRouteWeight": "Počiatočná váha trasy",
@@ -1941,7 +1934,7 @@
"appSettings_maxMessageRetriesSubtitle": "Počet pokusov o odošleť pred označením správy ako neúspešnej",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Režim telemetrie bol aktualizovaný",
"settings_multiAck": "Viaceré ACK: {value}",
"settings_multiAck": "Viaceré ACK",
"map_showOverlaps": "Prekrývanie opakovača kľúča",
"map_runTraceWithReturnPath": "Vráťte sa späť po tej istej ceste.",
"@radioStats_noiseFloor": {
@@ -2061,5 +2054,17 @@
"translation_translationOptions": "Možnosti prekladania",
"translation_systemLanguage": "Jazyk systému",
"repeater_cliQuickClockSync": "Synchronizácia hodin",
"repeater_cliQuickDiscovery": "Objaviť susedov"
"repeater_cliQuickDiscovery": "Objaviť susedov",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "Synchronizácia hodiniek po prihlávení",
"repeater_clockSyncAfterLoginSubtitle": "Automaticky posielajte notifikáciu \"synchronizácia času\" po úspešnom prihládení.",
"chat_sendMessage": "Odoslať správu",
"repeater_guest": "Informácie o opakovači",
"room_guest": "Informácie o serveri",
"repeater_guestTools": "Nástroje pre hostí"
}
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_teleEnv": "Okolje telemetrije",
"contact_teleEnvSubtitle": "Dovoli deljenje podatkov okoljskih senzorjev",
"contact_teleLocSubtitle": "Dovoli deljenje podatkov o lokaciji",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.",
"appSettings_initialRouteWeight": "Izvirna teža poti",
"appSettings_initialRouteWeightSubtitle": "Izguba teže za nove, odkriti poti",
@@ -1940,7 +1933,6 @@
"appSettings_maxMessageRetries": "Najve število poskusov pošiljanja sporočil",
"appSettings_maxMessageRetriesSubtitle": "Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "Večkratni potrditvi: {value}",
"settings_telemetryModeUpdated": "Način telemetrije posodobljen",
"map_showOverlaps": "Prekrivanje ključa ponovnega predvajanja",
"map_runTraceWithReturnPath": "Vrni se nazaj po isti poti.",
@@ -2061,5 +2053,18 @@
"scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).",
"scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje",
"repeater_cliQuickDiscovery": "Odkrijte sosede",
"repeater_cliQuickClockSync": "Usklajevanje ure"
"repeater_cliQuickClockSync": "Usklajevanje ure",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Samodejno po uspešnem vstopu pošljite obvestilo o sinhronizaciji časa.",
"repeater_clockSyncAfterLogin": "Sinhronizacija ure po prijavi",
"repeater_guest": "Informacije o ponovljalniku",
"chat_sendMessage": "Pošlji sporočilo",
"room_guest": "Informacije o strežniku",
"repeater_guestTools": "Naložila za goste",
"settings_multiAck": "Več potrdil"
}
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_teleBaseSubtitle": "Tillåt delning av batterinivå och grundläggande telemetri",
"contact_teleLoc": "Telemetridata plats",
"contact_teleLocSubtitle": "Tillåt delning av platsdata",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeightSubtitle": "Initial vikt för nyligen upptäckta vägar",
"appSettings_maxRouteWeight": "Maximalt tillåtet vikt för rutten",
"appSettings_maxRouteWeightSubtitle": "Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.",
@@ -1941,7 +1934,6 @@
"appSettings_maxMessageRetriesSubtitle": "Antal försök att skicka om ett meddelande innan det markeras som misslyckat.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Telemetri-läge uppdaterat",
"settings_multiAck": "Multi-ACKs: {value}",
"map_showOverlaps": "Repeater-nyckelöverlappningar",
"map_runTraceWithReturnPath": "Gå tillbaka på samma väg",
"@radioStats_noiseFloor": {
@@ -2061,5 +2053,18 @@
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
"scanner_linuxPairingHidePin": "Dölj PIN",
"repeater_cliQuickDiscovery": "Upptäck grannar",
"repeater_cliQuickClockSync": "Synkronisera klocka"
"repeater_cliQuickClockSync": "Synkronisera klocka",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Automatiskt skicka \"klocksynkronisering\" efter en lyckad inloggning.",
"repeater_clockSyncAfterLogin": "Synkronisera klockan efter inloggning",
"repeater_guest": "Information om repetorer",
"chat_sendMessage": "Skicka meddelande",
"repeater_guestTools": "Gästverktyg",
"room_guest": "Information om servern",
"settings_multiAck": "Flera bekräftelser"
}
+14 -9
View File
@@ -1922,13 +1922,6 @@
"contact_lastSeen": "Останній раз бачили",
"contact_teleEnv": "Середовище телеметрії",
"contact_teleEnvSubtitle": "Дозволити спільний доступ до даних датчиків середовища",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Початкова вартість маршруту",
"appSettings_initialRouteWeightSubtitle": "Початкова вага для нових відкритих шляхів",
"appSettings_maxRouteWeight": "Максимальна вага маршруту",
@@ -1941,7 +1934,6 @@
"appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Режим телеметрії оновлено",
"settings_multiAck": "Багатократне підтвердження: {value}",
"map_showOverlaps": "Перекриття ключа повторювача",
"map_runTraceWithReturnPath": "Повернутися назад тим же шляхом",
"@radioStats_noiseFloor": {
@@ -2061,5 +2053,18 @@
"scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).",
"scanner_linuxPairingHidePin": "Приховати PIN",
"repeater_cliQuickClockSync": "Синхронізація годинника",
"repeater_cliQuickDiscovery": "Відкрити сусідів"
"repeater_cliQuickDiscovery": "Відкрити сусідів",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLoginSubtitle": "Автоматично надсилати повідомлення \"синхронізація годин\" після успішного входу.",
"repeater_clockSyncAfterLogin": "Синхронізація годин після входу",
"repeater_guestTools": "Інструменти для гостей",
"repeater_guest": "Інформація про ретранслятор",
"room_guest": "Інформація про сервер кімнати",
"chat_sendMessage": "Надіслати повідомлення",
"settings_multiAck": "Багато підтверджень"
}
+14 -9
View File
@@ -1927,13 +1927,6 @@
"contact_settings": "联系人设置",
"contact_teleLocSubtitle": "允许共享位置数据",
"contact_telemetry": "遥测数据",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeight": "最大路径重量",
"appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量",
"appSettings_initialRouteWeight": "初始路线权重",
@@ -1945,7 +1938,7 @@
"appSettings_maxMessageRetries": "最大消息重试次数",
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "多重ACK{value}",
"settings_multiAck": "多重ACK",
"settings_telemetryModeUpdated": "遥测模式已更新",
"map_showOverlaps": "重复键重叠",
"map_runTraceWithReturnPath": "沿着相同的路径返回",
@@ -2066,5 +2059,17 @@
"translation_translationOptions": "翻译选项",
"translation_systemLanguage": "系统语言",
"repeater_cliQuickDiscovery": "发现邻居",
"repeater_cliQuickClockSync": "同步时钟"
"repeater_cliQuickClockSync": "同步时钟",
"@repeater_clockSyncAfterLogin": {
"description": "Repeater setting: auto sync device clock after successful login"
},
"@repeater_clockSyncAfterLoginSubtitle": {
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
},
"repeater_clockSyncAfterLogin": "登录后,自动同步时钟",
"repeater_clockSyncAfterLoginSubtitle": "在成功登录后,自动发送“时钟同步”指令。",
"repeater_guestTools": "访客工具",
"repeater_guest": "重复器信息",
"chat_sendMessage": "发送消息",
"room_guest": "服务器信息"
}
+14 -89
View File
@@ -1,16 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'screens/channel_chat_screen.dart';
import 'screens/chat_screen.dart';
import 'screens/chrome_required_screen.dart';
import 'screens/discovery_screen.dart';
import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart';
@@ -131,7 +125,7 @@ https://creativecommons.org/licenses/by/4.0/
});
}
class MeshCoreApp extends StatefulWidget {
class MeshCoreApp extends StatelessWidget {
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
@@ -161,94 +155,26 @@ class MeshCoreApp extends StatefulWidget {
required this.timeoutPredictionService,
});
@override
State<MeshCoreApp> createState() => _MeshCoreAppState();
}
class _MeshCoreAppState extends State<MeshCoreApp> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
StreamSubscription<NotificationTapEvent>? _notificationTapSubscription;
@override
void initState() {
super.initState();
_notificationTapSubscription = NotificationService().onNotificationTapped
.listen(_handleNotificationTap);
}
@override
void dispose() {
_notificationTapSubscription?.cancel();
super.dispose();
}
void _handleNotificationTap(NotificationTapEvent event) {
final navigator = _navigatorKey.currentState;
if (navigator == null) return;
switch (event.type) {
case NotificationTapEventType.message:
if (event.id == null) return;
final contact = widget.connector.findContactByKeyHex(event.id!);
if (contact == null) return;
widget.connector.markContactRead(contact.publicKeyHex);
navigator.push(
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
break;
case NotificationTapEventType.channel:
if (event.id == null) return;
final channelIndex = int.tryParse(event.id!);
if (channelIndex == null) return;
final channel = widget.connector.findChannelByIndex(channelIndex);
if (channel == null) return;
widget.connector.markChannelRead(channelIndex);
navigator.push(
MaterialPageRoute(
builder: (_) => ChannelChatScreen(channel: channel),
),
);
break;
case NotificationTapEventType.advert:
// Clear every advert notification the discovery
// list the user is about to see contains them all.
NotificationService().clearAllAdvertNotifications();
final ids = widget.connector.allContacts
.map((c) => c.publicKeyHex)
.toList();
NotificationService().clearAdvertNotifications(ids);
navigator.push(
MaterialPageRoute(builder: (_) => const DiscoveryScreen()),
);
break;
case NotificationTapEventType.batch:
// Batch summaries have no single target; no-op.
break;
}
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: widget.connector),
ChangeNotifierProvider.value(value: widget.retryService),
ChangeNotifierProvider.value(value: widget.pathHistoryService),
ChangeNotifierProvider.value(value: widget.appSettingsService),
ChangeNotifierProvider.value(value: widget.bleDebugLogService),
ChangeNotifierProvider.value(value: widget.appDebugLogService),
ChangeNotifierProvider.value(value: widget.chatTextScaleService),
ChangeNotifierProvider.value(value: widget.translationService),
ChangeNotifierProvider.value(value: widget.uiViewStateService),
Provider.value(value: widget.storage),
Provider.value(value: widget.mapTileCacheService),
ChangeNotifierProvider.value(value: widget.timeoutPredictionService),
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: translationService),
ChangeNotifierProvider.value(value: uiViewStateService),
Provider.value(value: storage),
Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
return WithForegroundTask(
child: MaterialApp(
navigatorKey: _navigatorKey,
return MaterialApp(
title: 'MeshCore Open',
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
@@ -290,7 +216,6 @@ class _MeshCoreAppState extends State<MeshCoreApp> {
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen()
: const ScannerScreen(),
),
);
},
),
+4 -2
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
class AppDebugLogScreen extends StatelessWidget {
const AppDebugLogScreen({super.key});
@@ -34,8 +35,9 @@ class AppDebugLogScreen extends StatelessWidget {
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.debugLog_copied)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.debugLog_copied),
);
}
: null,
+41 -43
View File
@@ -10,6 +10,7 @@ import '../services/app_settings_service.dart';
import '../services/notification_service.dart';
import '../services/translation_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'map_cache_screen.dart';
class AppSettingsScreen extends StatelessWidget {
@@ -151,13 +152,12 @@ class AppSettingsScreen extends StatelessWidget {
.requestPermissions();
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.appSettings_notificationPermissionDenied,
),
duration: const Duration(seconds: 2),
),
);
}
return;
@@ -166,15 +166,14 @@ class AppSettingsScreen extends StatelessWidget {
await settingsService.setNotificationsEnabled(value);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
value
? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled,
),
duration: const Duration(seconds: 2),
),
);
}
},
@@ -301,15 +300,14 @@ class AppSettingsScreen extends StatelessWidget {
value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
value
? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared,
),
duration: const Duration(seconds: 2),
),
);
},
),
@@ -329,15 +327,14 @@ class AppSettingsScreen extends StatelessWidget {
value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
value
? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled,
),
duration: const Duration(seconds: 2),
),
);
},
),
@@ -1065,25 +1062,25 @@ class AppSettingsScreen extends StatelessWidget {
children: [
Text(context.l10n.appSettings_showNodesDiscoveredWithin),
const SizedBox(height: 16),
ListTile(
RadioListTile<double>(
title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>(value: 0),
value: 0,
),
ListTile(
RadioListTile<double>(
title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>(value: 1),
value: 1,
),
ListTile(
RadioListTile<double>(
title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>(value: 6),
value: 6,
),
ListTile(
RadioListTile<double>(
title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>(value: 24),
value: 24,
),
ListTile(
RadioListTile<double>(
title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>(value: 168),
value: 168,
),
],
),
@@ -1117,13 +1114,13 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
RadioListTile<UnitSystem>(
title: Text(context.l10n.appSettings_unitsMetric),
leading: const Radio<UnitSystem>(value: UnitSystem.metric),
value: UnitSystem.metric,
),
ListTile(
RadioListTile<UnitSystem>(
title: Text(context.l10n.appSettings_unitsImperial),
leading: const Radio<UnitSystem>(value: UnitSystem.imperial),
value: UnitSystem.imperial,
),
],
),
@@ -1164,8 +1161,9 @@ class AppSettingsScreen extends StatelessWidget {
String? id,
}) async {
if (sourceUrl.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.translation_enterUrlFirst)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.translation_enterUrlFirst),
);
return;
}
@@ -1176,23 +1174,24 @@ class AppSettingsScreen extends StatelessWidget {
id: id,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.translation_modelDownloaded)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.translation_modelDownloaded),
);
await settingsService.setTranslationEnabled(true);
} on TranslationDownloadCancelled {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.translation_downloadStopped)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.translation_downloadStopped),
);
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.translation_downloadFailed(error.toString()),
),
),
);
}
}
@@ -1236,16 +1235,16 @@ class AppSettingsScreen extends StatelessWidget {
try {
await translationService.removeModel(model);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
// TODO: l10n
content: Text('Deleted ${translationModelFriendlyName(model)}.'),
),
);
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Delete failed: $error')),
showDismissibleSnackBar(
context,
content: Text('Delete failed: $error'),
); // TODO: l10n
}
}
@@ -1279,15 +1278,14 @@ class AppSettingsScreen extends StatelessWidget {
onChanged: (value) async {
await settingsService.setAppDebugLogEnabled(value);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
value
? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled,
),
duration: const Duration(seconds: 2),
),
);
},
),
+3 -3
View File
@@ -5,6 +5,7 @@ import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
enum _BleLogView { frames, rawLogRx }
@@ -52,10 +53,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.debugLog_bleCopied),
),
);
}
: null,
+38 -24
View File
@@ -13,7 +13,7 @@ import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/gif_helper.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
@@ -22,6 +22,7 @@ import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../utils/emoji_utils.dart';
import '../widgets/byte_count_input.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
@@ -144,11 +145,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Future<void> _scrollToMessage(String messageId) async {
final key = _messageKeys[messageId];
if (key == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_originalMessageNotFound),
duration: const Duration(seconds: 2),
),
);
return;
}
@@ -1093,27 +1093,33 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
);
}
return TextField(
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(),
encoder:
connector.isChannelSmazEnabled(widget.channel.index)
? (text) => connector.prepareChannelOutboundText(
widget.channel.index,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
horizontal: 20,
vertical: 14,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
@@ -1151,9 +1157,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final now = DateTime.now();
if (_lastChannelSendAt != null &&
now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) {
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
content: Text(context.l10n.chat_sendCooldown),
);
return;
}
_lastChannelSendAt = now;
@@ -1194,9 +1201,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
final outboundText = connector.prepareChannelOutboundText(
widget.channel.index,
messageText,
);
if (utf8.encode(outboundText).length > maxBytes) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_messageTooLong(maxBytes)),
);
return;
}
@@ -1323,17 +1335,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
content: Text(context.l10n.chat_messageCopied),
);
}
Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
content: Text(context.l10n.chat_messageDeleted),
);
}
String _formatPathPrefixes(Uint8List pathBytes) {
+41 -72
View File
@@ -24,6 +24,7 @@ import '../widgets/empty_state.dart';
import '../widgets/qr_code_display.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/unread_badge.dart';
import '../helpers/snack_bar_builder.dart';
import 'channel_chat_screen.dart';
import 'community_qr_scanner_screen.dart';
import 'contacts_screen.dart';
@@ -809,16 +810,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
dialogContext,
).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
dialogContext
.l10n
.channels_enterChannelName,
),
),
);
return;
}
@@ -837,13 +835,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
nextIndex,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.channels_channelAdded(
name,
),
),
context.l10n.channels_channelAdded(name),
),
);
}
@@ -897,16 +892,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final name = nameController.text.trim();
final pskHex = pskController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
dialogContext,
).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
dialogContext
.l10n
.channels_enterChannelName,
),
),
);
return;
}
@@ -914,29 +906,23 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(
dialogContext,
).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
dialogContext
.l10n
.channels_pskMustBe32Hex,
),
),
);
return;
}
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.channels_channelAdded(
name,
),
),
context.l10n.channels_channelAdded(name),
),
);
}
@@ -967,12 +953,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, 'Public', psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.channels_publicChannelAdded,
),
),
);
}
},
@@ -1097,16 +1082,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
onPressed: () async {
var hashtag = hashtagController.text.trim();
if (hashtag.isEmpty) {
ScaffoldMessenger.of(
dialogContext,
).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
dialogContext
.l10n
.channels_enterChannelName,
),
),
);
return;
}
@@ -1125,16 +1107,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} else {
// Community hashtag - HMAC derivation from community secret
if (selectedCommunity == null) {
ScaffoldMessenger.of(
showDismissibleSnackBar(
dialogContext,
).showSnackBar(
SnackBar(
content: Text(
dialogContext
.l10n
.community_selectCommunity,
),
),
);
return;
}
@@ -1159,14 +1138,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.channels_channelAdded(
channelName,
),
),
),
);
}
},
@@ -1259,14 +1237,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
dialogContext,
).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
dialogContext.l10n.community_enterName,
),
),
);
return;
}
@@ -1301,12 +1276,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
_loadCommunities();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.community_created(name),
),
),
);
// Show QR code dialog
@@ -1494,10 +1468,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(
showDismissibleSnackBar(
dialogContext,
content: Text(dialogContext.l10n.channels_pskMustBe32Hex),
),
);
return;
}
@@ -1510,16 +1483,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
smazEnabled,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.channels_channelUpdated(name)),
),
);
} catch (e, st) {
debugPrint(st.toString());
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update channel: $e')),
showDismissibleSnackBar(
context,
content: Text('Failed to update channel: $e'),
);
}
},
@@ -1559,22 +1532,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.channels_channelDeleted(channel.name),
),
),
);
} catch (e, st) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.channels_channelDeleteFailed(channel.name),
),
),
);
// Preserve existing logging (if it was there)
@@ -1594,8 +1565,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
connector.setChannel(0, 'Public', psk);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.channels_publicChannelAdded)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.channels_publicChannelAdded),
);
}
@@ -1810,12 +1782,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
_loadCommunities();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.community_deleted(community.name),
),
),
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_deleted(community.name)),
);
}
},
+55 -41
View File
@@ -18,7 +18,6 @@ import '../widgets/message_status_icon.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/gif_helper.dart';
import '../helpers/path_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
@@ -30,6 +29,7 @@ import '../services/path_history_service.dart';
import '../services/translation_service.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/elements_ui.dart';
import '../widgets/byte_count_input.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../utils/emoji_utils.dart';
@@ -43,6 +43,7 @@ import '../widgets/radio_stats_entry.dart';
import '../widgets/translated_message_content.dart';
import '../utils/app_logger.dart';
import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart';
import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget {
@@ -566,24 +567,35 @@ class _ChatScreenState extends State<ChatScreen> {
),
);
}
return TextField(
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(connector),
encoder:
connector.isContactSmazEnabled(
widget.contact.publicKeyHex,
)
? (text) => connector.prepareContactOutboundText(
widget.contact,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
horizontal: 20,
vertical: 14,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
);
},
),
@@ -633,9 +645,10 @@ class _ChatScreenState extends State<ChatScreen> {
final now = DateTime.now();
if (_lastTextSendAt != null &&
now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) {
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
content: Text(context.l10n.chat_sendCooldown),
);
return;
}
_lastTextSendAt = now;
@@ -670,9 +683,14 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
final maxBytes = maxContactMessageBytes();
if (utf8.encode(outgoingText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
final outboundText = connector.prepareContactOutboundText(
_resolveContact(connector),
outgoingText,
);
if (utf8.encode(outboundText).length > maxBytes) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_messageTooLong(maxBytes)),
);
return;
}
@@ -860,15 +878,12 @@ class _ChatScreenState extends State<ChatScreen> {
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context
.l10n
.chat_pathDetailsNotAvailable,
context.l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
return;
}
@@ -952,11 +967,10 @@ class _ChatScreenState extends State<ChatScreen> {
_resolveContact(connector),
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
},
@@ -982,11 +996,10 @@ class _ChatScreenState extends State<ChatScreen> {
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
},
@@ -1020,11 +1033,10 @@ class _ChatScreenState extends State<ChatScreen> {
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
}
@@ -1137,11 +1149,10 @@ class _ChatScreenState extends State<ChatScreen> {
: (verified
? context.l10n.chat_pathDeviceConfirmed
: context.l10n.chat_pathDeviceNotConfirmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
duration: const Duration(seconds: 3),
),
);
}
@@ -1490,26 +1501,29 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
content: Text(context.l10n.chat_messageCopied),
);
}
Future<void> _deleteMessage(Message message) async {
await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
content: Text(context.l10n.chat_messageDeleted),
);
}
void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting
connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
content: Text(context.l10n.chat_retryingMessage),
);
}
void _showEmojiPicker(Message message, Contact senderContact) {
+7 -9
View File
@@ -8,6 +8,7 @@ import '../models/community.dart';
import '../storage/community_store.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/qr_scanner_widget.dart';
import '../helpers/snack_bar_builder.dart';
/// Screen for scanning community QR codes to join communities.
///
@@ -76,11 +77,10 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.red,
),
);
}
} finally {
@@ -93,12 +93,11 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
}
void _showInvalidQrError(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
@@ -229,11 +228,10 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green,
),
);
// Return to previous screen
+50 -48
View File
@@ -27,6 +27,7 @@ import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../widgets/unread_badge.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'chat_screen.dart';
import 'discovery_screen.dart';
@@ -150,9 +151,10 @@ class _ContactsScreenState extends State<ContactsScreen>
}
void _showGroupsUnavailableMessage(BuildContext context) {
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.common_loading)));
content: Text(context.l10n.common_loading),
);
}
void _setupFrameListener() {
@@ -169,10 +171,9 @@ class _ContactsScreenState extends State<ContactsScreen>
// Validate packet has expected minimum size (98+ bytes per protocol)
if (advertPacket.length < 98) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_invalidAdvertFormat),
),
);
}
_pendingOperations.remove(ContactOperationType.export);
@@ -187,24 +188,23 @@ class _ContactsScreenState extends State<ContactsScreen>
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImported)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImported),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
),
);
}
@@ -216,25 +216,22 @@ class _ContactsScreenState extends State<ContactsScreen>
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
),
);
}
@@ -271,8 +268,9 @@ class _ContactsScreenState extends State<ContactsScreen>
final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData == null || clipboardData.text == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_clipboardEmpty),
);
}
return;
@@ -280,8 +278,9 @@ class _ContactsScreenState extends State<ContactsScreen>
final text = clipboardData.text!.trim();
if (!text.startsWith('meshcore://')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_invalidAdvertFormat),
);
}
return;
@@ -294,8 +293,9 @@ class _ContactsScreenState extends State<ContactsScreen>
connector.importContact(importContactFrame);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_invalidAdvertFormat),
);
}
}
@@ -330,11 +330,10 @@ class _ContactsScreenState extends State<ContactsScreen>
),
onTap: () => {
connector.sendSelfAdvert(flood: false),
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_advertisementSent),
),
),
},
),
PopupMenuItem(
@@ -347,11 +346,10 @@ class _ContactsScreenState extends State<ContactsScreen>
),
onTap: () => {
connector.sendSelfAdvert(flood: true),
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_advertisementSent),
),
),
},
),
PopupMenuItem(
@@ -963,13 +961,16 @@ class _ContactsScreenState extends State<ContactsScreen>
context: context,
builder: (context) => RepeaterLoginDialog(
repeater: repeater,
onLogin: (password) {
onLogin: (password, isAdmin) {
// Navigate to repeater hub screen after successful login
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
RepeaterHubScreen(repeater: repeater, password: password),
builder: (context) => RepeaterHubScreen(
repeater: repeater,
password: password,
isAdmin: isAdmin,
),
),
);
},
@@ -986,14 +987,18 @@ class _ContactsScreenState extends State<ContactsScreen>
context: context,
builder: (context) => RoomLoginDialog(
room: room,
onLogin: (password) {
onLogin: (password, isAdmin) {
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
destination == RoomLoginDestination.management
? RepeaterHubScreen(repeater: room, password: password)
? RepeaterHubScreen(
repeater: room,
password: password,
isAdmin: isAdmin,
)
: ChatScreen(contact: room),
),
);
@@ -1146,19 +1151,17 @@ class _ContactsScreenState extends State<ContactsScreen>
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_groupNameRequired),
),
);
return;
}
if (name.toLowerCase() ==
contactsAllGroupsValue.toLowerCase()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_groupNameReserved),
),
);
return;
}
@@ -1167,12 +1170,11 @@ class _ContactsScreenState extends State<ContactsScreen>
return g.name.toLowerCase() == name.toLowerCase();
});
if (exists) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.contacts_groupAlreadyExists(name),
),
),
);
return;
}
+4 -17
View File
@@ -8,11 +8,11 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../services/notification_service.dart';
import '../utils/contact_search.dart';
import '../utils/platform_info.dart';
import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart';
import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type }
@@ -32,20 +32,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen;
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_clearAdvertNotifications();
}
void _clearAdvertNotifications() {
final connector = context.read<MeshCoreConnector>();
final ids = connector.allContacts.map((c) => c.publicKeyHex).toList();
final ns = NotificationService();
ns.clearAllAdvertNotifications();
ns.clearAdvertNotifications(ids);
}
@override
void dispose() {
_searchController.dispose();
@@ -249,8 +235,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
);
break;
case 'delete_contact':
+11 -9
View File
@@ -8,6 +8,7 @@ import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key});
@@ -112,15 +113,17 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Future<void> _startDownload() async {
final bounds = _selectedBounds;
if (bounds == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.mapCache_selectAreaFirst)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.mapCache_selectAreaFirst),
);
return;
}
if (_estimatedTiles == 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.mapCache_noTilesToDownload)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.mapCache_noTilesToDownload),
);
return;
}
@@ -182,9 +185,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
result.failed,
)
: context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
showDismissibleSnackBar(context, content: Text(message));
}
Future<void> _clearCache() async {
@@ -210,8 +211,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final cacheService = context.read<MapTileCacheService>();
await cacheService.clearCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.mapCache_offlineCacheCleared)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.mapCache_offlineCacheCleared),
);
}
+19 -9
View File
@@ -29,6 +29,7 @@ import 'chat_screen.dart';
import 'contacts_screen.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../helpers/snack_bar_builder.dart';
import 'repeater_hub_screen.dart';
import 'settings_screen.dart';
import 'line_of_sight_map_screen.dart';
@@ -1366,13 +1367,16 @@ class _MapScreenState extends State<MapScreen> {
context: context,
builder: (context) => RepeaterLoginDialog(
repeater: repeater,
onLogin: (password) {
onLogin: (password, isAdmin) {
// Navigate to repeater hub screen after successful login
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
RepeaterHubScreen(repeater: repeater, password: password),
builder: (context) => RepeaterHubScreen(
repeater: repeater,
password: password,
isAdmin: isAdmin,
),
),
);
},
@@ -1385,7 +1389,8 @@ class _MapScreenState extends State<MapScreen> {
context: context,
builder: (context) => RoomLoginDialog(
room: room,
onLogin: (password) {
// onLogin(password, isAdmin) isAdmin not used for room caht screen
onLogin: (password, _) {
// Navigate to chat screen after successful login
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
Navigator.push(
@@ -1659,7 +1664,10 @@ class _MapScreenState extends State<MapScreen> {
);
await connector.refreshDeviceInfo();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
showDismissibleSnackBar(
messenger.context,
content: Text(successMsg),
);
},
),
ListTile(
@@ -1681,8 +1689,9 @@ class _MapScreenState extends State<MapScreen> {
required String flags,
}) async {
if (!connector.isConnected) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.map_connectToShareMarkers)),
showDismissibleSnackBar(
context,
content: Text(context.l10n.map_connectToShareMarkers),
);
return;
}
@@ -2271,8 +2280,9 @@ class _MapScreenState extends State<MapScreen> {
_points.clear();
_polylines.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
showDismissibleSnackBar(
context,
content: Text(l10n.map_pathTraceCancelled),
);
},
tooltip: l10n.common_cancel,
+7 -9
View File
@@ -11,6 +11,7 @@ import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
import '../widgets/snr_indicator.dart';
import '../helpers/snack_bar_builder.dart';
class NeighborsScreen extends StatefulWidget {
final Contact repeater;
@@ -163,11 +164,10 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
_neighborCount = neighborCount;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_receivedData),
backgroundColor: Colors.green,
),
);
_statusTimeout?.cancel();
if (!mounted) return;
@@ -224,11 +224,10 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_requestTimedOut),
backgroundColor: Colors.red,
),
);
_recordStatusResult(false);
});
@@ -239,11 +238,10 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.neighbors_errorLoading(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
+4 -2
View File
@@ -9,6 +9,7 @@ import '../connector/meshcore_protocol.dart';
import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterCliScreen extends StatefulWidget {
final Contact repeater;
@@ -336,8 +337,9 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.repeater_enterCommandFirst)),
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_enterCommandFirst),
);
}
},
+17 -3
View File
@@ -13,11 +13,13 @@ import 'neighbors_screen.dart';
class RepeaterHubScreen extends StatelessWidget {
final Contact repeater;
final String password;
final bool isAdmin;
const RepeaterHubScreen({
super.key,
required this.repeater,
required this.password,
required this.isAdmin,
});
@override
@@ -33,11 +35,18 @@ class RepeaterHubScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_management
: l10n.room_management,
),
if (!isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_guest
: l10n.room_guest,
),
Text(
repeater.name,
style: const TextStyle(
@@ -113,6 +122,7 @@ class RepeaterHubScreen extends StatelessWidget {
),
),
const SizedBox(height: 24),
if (isAdmin)
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
@@ -170,7 +180,9 @@ class RepeaterHubScreen extends StatelessWidget {
),
const SizedBox(height: 24),
Text(
l10n.repeater_managementTools,
isAdmin
? l10n.repeater_managementTools
: l10n.repeater_guestTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
@@ -210,8 +222,9 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
const SizedBox(height: 12),
if (isAdmin) const SizedBox(height: 12),
// CLI button
if (isAdmin)
_buildManagementCard(
context,
icon: Icons.terminal,
@@ -248,8 +261,9 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
const SizedBox(height: 12),
if (isAdmin) const SizedBox(height: 12),
// Settings button
if (isAdmin)
_buildManagementCard(
context,
icon: Icons.settings,
+45 -19
View File
@@ -8,7 +8,9 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/app_debug_log_service.dart';
import '../services/repeater_command_service.dart';
import '../services/storage_service.dart';
import '../widgets/path_management_dialog.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterSettingsScreen extends StatefulWidget {
final Contact repeater;
@@ -25,6 +27,8 @@ class RepeaterSettingsScreen extends StatefulWidget {
}
class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
final StorageService _storage = StorageService();
bool _isLoading = false;
bool _hasChanges = false;
bool _refreshingBasic = false;
@@ -59,6 +63,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
bool _repeatEnabled = true;
bool _allowReadOnly = true;
bool _privacyMode = false;
bool _autoClockSyncAfterLogin = false;
// Advertisement settings
bool _advertEnable = true;
@@ -464,18 +469,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
if (successCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_refreshed(label)),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_errorRefreshing(label)),
backgroundColor: Colors.red,
),
);
}
@@ -566,6 +569,15 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_lonController.text = widget.repeater.longitude?.toString() ?? '';
}
});
final autoClockSync = await _storage
.getRepeaterAutoClockSyncAfterLoginEnabled(
widget.repeater.publicKeyHex,
);
if (!mounted) return;
setState(() {
_autoClockSyncAfterLogin = autoClockSync;
});
}
Future<void> _saveSettings() async {
@@ -653,11 +665,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_settingsSaved),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
@@ -666,13 +677,12 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
context.l10n.repeater_errorSavingSettings(e.toString()),
),
backgroundColor: Colors.red,
),
);
}
}
@@ -1139,6 +1149,21 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
onRefresh: _refreshAllowReadOnly,
refreshTooltip: l10n.repeater_refreshGuestAccess,
),
SwitchListTile(
title: Text(l10n.repeater_clockSyncAfterLogin),
subtitle: Text(l10n.repeater_clockSyncAfterLoginSubtitle),
value: _autoClockSyncAfterLogin,
onChanged: (value) async {
setState(() {
_autoClockSyncAfterLogin = value;
});
await _storage.setRepeaterAutoClockSyncAfterLoginEnabled(
widget.repeater.publicKeyHex,
value,
);
},
contentPadding: EdgeInsets.zero,
),
// Privacy mode - hidden until fully implemented
// _buildFeatureToggleRow(
// title: l10n.repeater_privacyMode,
@@ -1401,9 +1426,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (command == 'erase') {
if (mounted) {
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(l10n.repeater_eraseSerialOnly)));
content: Text(l10n.repeater_eraseSerialOnly),
);
}
return;
}
@@ -1425,17 +1451,17 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
await connector.sendFrame(frame);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.repeater_commandSent(command))),
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_commandSent(command)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_errorSendingCommand(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
+6 -9
View File
@@ -12,6 +12,7 @@ import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../utils/battery_utils.dart';
import '../widgets/path_management_dialog.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterStatusScreen extends StatefulWidget {
final Contact repeater;
@@ -309,11 +310,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red,
),
);
_recordStatusResult(false);
});
@@ -323,13 +323,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.repeater_errorLoadingStatus(e.toString()),
),
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
backgroundColor: Colors.red,
),
);
}
_recordStatusResult(false);
+3 -14
View File
@@ -7,10 +7,10 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart';
import '../services/notification_service.dart';
import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart';
import 'tcp_screen.dart';
import 'usb_screen.dart';
@@ -44,10 +44,6 @@ class _ScannerScreenState extends State<ScannerScreen> {
isCurrentRoute &&
!_changedNavigation) {
_changedNavigation = true;
// Prompt for notification permission on first
// connect so notifications work out of the box
// on Android 13+.
NotificationService().requestPermissions();
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ContactsScreen()),
@@ -58,12 +54,6 @@ class _ScannerScreenState extends State<ScannerScreen> {
_connector.addListener(_connectionListener);
// If the app was killed (swipe-away) and relaunched, try to reconnect
// to the last known device so the user doesn't have to scan again.
if (_connector.state == MeshCoreConnectionState.disconnected) {
_connector.tryAutoReconnect();
}
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
(state) {
if (mounted) {
@@ -328,11 +318,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
return;
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.scanner_connectionFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
+59 -51
View File
@@ -11,6 +11,7 @@ import '../l10n/l10n.dart';
import '../models/radio_settings.dart';
import '../services/app_debug_log_service.dart';
import '../widgets/app_bar.dart';
import '../helpers/snack_bar_builder.dart';
import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart';
@@ -513,8 +514,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.setNodeName(controller.text);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_nodeNameUpdated)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_nodeNameUpdated),
);
},
child: Text(l10n.common_save),
@@ -628,10 +630,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final interval = int.tryParse(intervalText);
if (interval == null || interval < 60 || interval >= 86400) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.settings_locationIntervalInvalid),
),
);
return;
}
@@ -639,8 +640,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.setCustomVar("gps_interval:$interval");
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationUpdated)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_locationUpdated),
);
}
@@ -660,15 +662,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
: currentLon;
if (lat == null || lon == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationBothRequired)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_locationBothRequired),
);
return;
}
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationInvalid)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_locationInvalid),
);
return;
}
@@ -676,8 +680,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.setNodeLocation(lat: lat, lon: lon);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationUpdated)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_locationUpdated),
);
},
child: Text(l10n.common_save),
@@ -691,9 +696,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _syncTime(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.syncTime();
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_timeSynchronized)));
content: Text(l10n.settings_timeSynchronized),
);
}
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
@@ -758,23 +764,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!mounted) return;
switch (result) {
case gpxExportSuccess:
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportSuccess)));
content: Text(l10n.settings_gpxExportSuccess),
);
case gpxExportNoContacts:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_gpxExportNoContacts)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_gpxExportNoContacts),
);
break;
case gpxExportNotAvailable:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_gpxExportNotAvailable)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_gpxExportNotAvailable),
);
break;
case gpxExportFailed:
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportError)));
content: Text(l10n.settings_gpxExportError),
);
break;
}
}
@@ -1001,6 +1011,15 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
},
),
const SizedBox(height: 8),
SwitchListTile(
title: Text(l10n.settings_multiAck),
value: multiAcks == 1,
onChanged: (value) {
setDialogState(() => multiAcks = value ? 1 : 0);
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: telemetryMode,
decoration: InputDecoration(
@@ -1042,21 +1061,6 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
}
},
),
const SizedBox(height: 16),
Text(
l10n.settings_multiAck(multiAcks.toString()),
style: Theme.of(context).textTheme.bodyMedium,
),
Slider(
value: multiAcks.toDouble(),
min: 0,
max: 2,
divisions: 2,
label: multiAcks.toString(),
onChanged: (value) {
setDialogState(() => multiAcks = value.round());
},
),
],
),
),
@@ -1077,8 +1081,9 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_telemetryModeUpdated)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_telemetryModeUpdated),
);
},
child: Text(l10n.common_save),
@@ -1410,18 +1415,18 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
final txPower = int.tryParse(_txPowerController.text);
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
ScaffoldMessenger.of(
showDismissibleSnackBar(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_frequencyInvalid)));
content: Text(l10n.settings_frequencyInvalid),
);
return;
}
final maxTxPower = widget.connector.maxTxPower ?? 22;
if (txPower == null || txPower < 0 || txPower > maxTxPower) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
),
);
return;
}
@@ -1441,8 +1446,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
if (knownRepeat) {
const validRepeatFreqsKHz = {433000, 869000, 918000};
if (_clientRepeat && !validRepeatFreqsKHz.contains(freqHz)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_clientRepeatFreqWarning)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_clientRepeatFreqWarning),
);
return;
}
@@ -1472,14 +1478,16 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
if (!mounted) return;
_logRadioSettingsState('Radio settings saved successfully');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_radioSettingsUpdated)),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_radioSettingsUpdated),
);
} catch (e) {
_appLog.warn('Radio settings save failed: $e', tag: 'RadioSettings');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_error(e.toString()))),
showDismissibleSnackBar(
context,
content: Text(l10n.settings_error(e.toString())),
);
}
Navigator.pop(context);
+5 -2
View File
@@ -8,6 +8,7 @@ import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart';
import 'usb_screen.dart';
@@ -270,8 +271,10 @@ class _TcpScreenState extends State<TcpScreen> {
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
showDismissibleSnackBar(
context,
content: Text(message),
backgroundColor: Colors.red,
);
}
+7 -9
View File
@@ -14,6 +14,7 @@ import '../utils/app_logger.dart';
import '../widgets/path_management_dialog.dart';
import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart';
class TelemetryScreen extends StatefulWidget {
final Contact contact;
@@ -86,11 +87,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
);
_recordTelemetryResult(false);
});
@@ -137,11 +137,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_parsedTelemetry = parsedTelemetry;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.telemetry_receivedData),
backgroundColor: Colors.green,
),
);
_statusTimeout?.cancel();
if (!mounted) return;
@@ -182,11 +181,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.telemetry_errorLoading(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
+3 -3
View File
@@ -10,6 +10,7 @@ import '../utils/app_logger.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart';
import 'scanner_screen.dart';
import 'tcp_screen.dart';
@@ -383,11 +384,10 @@ class _UsbScreenState extends State<UsbScreen> {
void _showError(Object error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(_friendlyErrorMessage(error)),
backgroundColor: Colors.red,
),
);
}
+8 -90
View File
@@ -1,30 +1,11 @@
import 'package:flutter/widgets.dart';
import '../utils/platform_info.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
/// Manages a foreground service (Android) and app lifecycle awareness
/// (Android + iOS) to keep the BLE connection alive when the app is
/// backgrounded or swiped away from the recents drawer.
class BackgroundService with WidgetsBindingObserver {
class BackgroundService {
bool _initialized = false;
bool _serviceRunning = false;
/// Optional callback invoked when the OS resumes the app after it was
/// paused or detached. The connector hooks this to trigger a reconnect
/// check so the BLE link is restored promptly.
VoidCallback? onResume;
/// Optional callback invoked when the app is about to be suspended.
/// The connector can use this to persist critical state.
VoidCallback? onPause;
Future<void> initialize() async {
if (_initialized) return;
// Register for app lifecycle events on all mobile platforms.
WidgetsBinding.instance.addObserver(this);
if (PlatformInfo.isAndroid) {
if (!PlatformInfo.isAndroid || _initialized) return;
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'meshcore_background',
@@ -43,79 +24,31 @@ class BackgroundService with WidgetsBindingObserver {
allowWifiLock: false,
),
);
}
_initialized = true;
}
Future<void> start() async {
if (!PlatformInfo.isMobile) return;
if (!PlatformInfo.isAndroid) return;
if (!_initialized) {
await initialize();
}
// Android: start the foreground service so the OS keeps the process alive
// even when the user swipes the app away.
if (PlatformInfo.isAndroid) {
final running = await FlutterForegroundTask.isRunningService;
if (!running) {
if (running) return;
await FlutterForegroundTask.startService(
notificationTitle: 'MeshCore running',
notificationText: 'Keeping BLE connected',
callback: startCallback,
);
}
}
// iOS: the bluetooth-central UIBackgroundMode (Info.plist) combined with
// CoreBluetooth state restoration (handled by flutter_blue_plus) keeps the
// BLE connection alive. No additional service is needed, but we track
// the logical "running" state so callers behave consistently.
_serviceRunning = true;
}
Future<void> stop() async {
if (!PlatformInfo.isMobile) return;
if (PlatformInfo.isAndroid) {
if (!PlatformInfo.isAndroid) return;
final running = await FlutterForegroundTask.isRunningService;
if (running) {
if (!running) return;
await FlutterForegroundTask.stopService();
}
}
_serviceRunning = false;
}
bool get isRunning => _serviceRunning;
// ---------------------------------------------------------------------------
// WidgetsBindingObserver app lifecycle
// ---------------------------------------------------------------------------
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
onResume?.call();
break;
case AppLifecycleState.paused:
case AppLifecycleState.detached:
onPause?.call();
break;
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break;
}
}
void dispose() {
WidgetsBinding.instance.removeObserver(this);
}
}
// ---------------------------------------------------------------------------
// Foreground-service isolate entry point (Android)
// ---------------------------------------------------------------------------
@pragma('vm:entry-point')
void startCallback() {
FlutterForegroundTask.setTaskHandler(_MeshCoreTaskHandler());
@@ -123,25 +56,10 @@ void startCallback() {
class _MeshCoreTaskHandler extends TaskHandler {
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
// The handler runs in a separate isolate. Its purpose is to keep the
// foreground-service notification alive so that Android does not kill
// the main isolate (where the BLE connection lives).
//
// Heavy BLE work stays in the main isolate; we just need the service
// to exist.
}
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
@override
void onRepeatEvent(DateTime timestamp) {
// Periodically update the notification so the system considers the
// service active. This also acts as a heartbeat.
FlutterForegroundTask.updateService(
notificationTitle: 'MeshCore running',
notificationText:
'Connected · ${timestamp.toLocal().hour.toString().padLeft(2, '0')}:${timestamp.toLocal().minute.toString().padLeft(2, '0')}',
);
}
void onRepeatEvent(DateTime timestamp) {}
@override
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
+38 -186
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io' show Platform, File;
import 'dart:ui';
@@ -9,21 +8,6 @@ import '../helpers/reaction_helper.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_info.dart';
enum NotificationTapEventType { message, channel, advert, batch }
/// Payload emitted when the user taps a notification.
class NotificationTapEvent {
// The type of notification tap event [NotificationTapEventType]
final NotificationTapEventType type;
/// For messages: the contact public key hex.
/// For channels: the channel index as a string.
/// For adverts: the contact public key hex.
final String? id;
const NotificationTapEvent({required this.type, this.id});
}
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
@@ -33,15 +17,6 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
/// Stream of notification tap events for navigation handling.
final StreamController<NotificationTapEvent> _tapController =
StreamController<NotificationTapEvent>.broadcast();
/// Listen to this stream to handle navigation when a notification
/// is tapped.
Stream<NotificationTapEvent> get onNotificationTapped =>
_tapController.stream;
// Locale for localized notification strings
Locale _locale = const Locale('en');
@@ -192,10 +167,6 @@ class NotificationService {
}) async {
if (!await _ensureInitialized()) return;
// Group per contact so each conversation is collapsible
// independently in the notification shade.
final groupKey = contactId != null ? 'msg_$contactId' : 'meshcore_messages';
final androidDetails = AndroidNotificationDetails(
'messages',
'Messages',
@@ -204,7 +175,6 @@ class NotificationService {
priority: Priority.high,
icon: '@mipmap/ic_launcher',
number: badgeCount,
groupKey: groupKey,
);
final iosDetails = DarwinNotificationDetails(
@@ -235,13 +205,6 @@ class NotificationService {
notificationDetails: notificationDetails,
payload: 'message:$contactId',
);
await _postGroupSummary(
groupKey: groupKey,
channelId: 'messages',
channelName: 'Messages',
title: contactName,
payload: 'message:$contactId',
);
} catch (e) {
debugPrint('Failed to show message notification: $e');
}
@@ -254,8 +217,6 @@ class NotificationService {
}) async {
if (!await _ensureInitialized()) return;
const groupKey = 'meshcore_adverts';
const androidDetails = AndroidNotificationDetails(
'adverts',
'Advertisements',
@@ -263,7 +224,6 @@ class NotificationService {
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
groupKey: groupKey,
);
const iosDetails = DarwinNotificationDetails(
@@ -294,15 +254,6 @@ class NotificationService {
notificationDetails: notificationDetails,
payload: 'advert:$contactId',
);
await _postGroupSummary(
groupKey: groupKey,
channelId: 'adverts',
channelName: 'Advertisements',
title: _l10n.notification_activityTitle,
payload: 'advert:',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
);
} catch (e) {
debugPrint('Failed to show advert notification: $e');
}
@@ -316,12 +267,6 @@ class NotificationService {
}) async {
if (!await _ensureInitialized()) return;
// Group per channel so each channel is collapsible
// independently in the notification shade.
final groupKey = channelIndex != null
? 'ch_$channelIndex'
: 'meshcore_channels';
final androidDetails = AndroidNotificationDetails(
'channel_messages',
'Channel Messages',
@@ -330,7 +275,6 @@ class NotificationService {
priority: Priority.high,
icon: '@mipmap/ic_launcher',
number: badgeCount,
groupKey: groupKey,
);
final iosDetails = DarwinNotificationDetails(
@@ -366,70 +310,11 @@ class NotificationService {
notificationDetails: notificationDetails,
payload: 'channel:$channelIndex',
);
await _postGroupSummary(
groupKey: groupKey,
channelId: 'channel_messages',
channelName: 'Channel Messages',
title: channelName,
payload: 'channel:$channelIndex',
);
} catch (e) {
debugPrint('Failed to show channel notification: $e');
}
}
// ---------------------------------------------------------------
// Android group summary helper
// ---------------------------------------------------------------
// Android requires a notification with setAsGroupSummary for
// each groupKey. This is what the user sees (and taps) when
// the OS collapses individual notifications in a group.
// ---------------------------------------------------------------
/// Post (or replace) the group summary notification for
/// [groupKey]. The summary's [payload] controls where tapping
/// the collapsed group navigates.
Future<void> _postGroupSummary({
required String groupKey,
required String channelId,
required String channelName,
required String title,
required String payload,
Importance importance = Importance.high,
Priority priority = Priority.high,
}) async {
if (!PlatformInfo.isAndroid) return;
final details = AndroidNotificationDetails(
channelId,
channelName,
importance: importance,
priority: priority,
icon: '@mipmap/ic_launcher',
groupKey: groupKey,
setAsGroupSummary: true,
);
// Use a stable ID derived from the groupKey so each
// group's summary replaces itself, never duplicates.
final summaryId = 'summary:$groupKey'.hashCode;
try {
await _notifications.show(
id: summaryId,
title: title,
body: null,
notificationDetails: NotificationDetails(android: details),
payload: payload,
);
} catch (e) {
debugPrint(
'Failed to post group summary '
'($groupKey): $e',
);
}
}
/// Returns a privacy-safe identifier for debug logging.
/// - advert: shows device name (body contains contactName)
/// - message: shows "from: sender" (avoids logging message content)
@@ -447,42 +332,14 @@ class NotificationService {
void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload;
if (payload == null) return;
if (payload != null) {
debugPrint('Notification tapped: $payload');
if (payload.startsWith('message:')) {
final contactId = payload.substring('message:'.length);
_tapController.add(
NotificationTapEvent(
type: NotificationTapEventType.message,
id: contactId,
),
);
} else if (payload.startsWith('channel:')) {
final channelIndex = payload.substring('channel:'.length);
_tapController.add(
NotificationTapEvent(
type: NotificationTapEventType.channel,
id: channelIndex,
),
);
} else if (payload.startsWith('advert:')) {
final contactId = payload.substring('advert:'.length);
_tapController.add(
NotificationTapEvent(
type: NotificationTapEventType.advert,
id: contactId,
),
);
} else if (payload == 'batch') {
_tapController.add(
const NotificationTapEvent(type: NotificationTapEventType.batch),
);
// Handle navigation based on payload
// This can be extended to navigate to specific screens
}
}
Future<void> cancelAll() async {
_pendingNotifications.clear();
await _notifications.cancelAll();
}
@@ -495,11 +352,6 @@ class NotificationService {
String contactId,
int totalUnreadCount,
) async {
// Purge any queued notifications for this contact so the batch timer
// doesn't re-post a notification the user has already seen.
_pendingNotifications.removeWhere(
(n) => n.type == _NotificationType.message && n.id == contactId,
);
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount);
@@ -510,13 +362,6 @@ class NotificationService {
int channelIndex,
int totalUnreadCount,
) async {
// Purge any queued notifications for this channel so the batch timer
// doesn't re-post a notification the user has already seen.
_pendingNotifications.removeWhere(
(n) =>
n.type == _NotificationType.channelMessage &&
n.id == channelIndex.toString(),
);
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount);
@@ -530,21 +375,6 @@ class NotificationService {
}
}
/// Cancel every advert notification including the group
/// summary. Called when the user opens the discovery list
/// (which shows all discovered nodes anyway).
Future<void> clearAllAdvertNotifications() async {
if (!await _ensureInitialized()) return;
// Cancel the group summary.
final summaryId = 'summary:meshcore_adverts'.hashCode;
await _notifications.cancel(id: summaryId);
// Individual adverts are cancelled by the OS when their
// group summary is removed, but on some OEMs we need to
// cancel them explicitly. We don't track IDs, so the
// caller should also pass known IDs through
// clearAdvertNotifications() when available.
}
Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update.
@@ -715,13 +545,7 @@ class NotificationService {
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!await _ensureInitialized()) return;
// Show each notification individually the Android
// groupKey on each type will cluster them automatically.
for (final notification in batch) {
await _showNotificationImmediately(notification);
}
// Debug logging
// Group by type
final messages = batch
.where((n) => n.type == _NotificationType.message)
.toList();
@@ -732,20 +556,48 @@ class NotificationService {
.where((n) => n.type == _NotificationType.channelMessage)
.toList();
// Build summary text using localized plurals
final parts = <String>[];
if (messages.isNotEmpty) {
parts.add('${messages.length} messages');
parts.add(_l10n.notification_messagesCount(messages.length));
}
if (channelMsgs.isNotEmpty) {
parts.add('${channelMsgs.length} channel msgs');
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length));
}
if (adverts.isNotEmpty) {
parts.add('${adverts.length} adverts');
parts.add(_l10n.notification_newNodesCount(adverts.length));
}
debugPrint(
'[Notification] batch dispatched: '
'${parts.join(", ")}',
if (parts.isEmpty) return;
// Show first few device names in batch summary for debugging (only if adverts exist)
final deviceInfo = adverts.isNotEmpty
? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
: '';
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
const androidDetails = AndroidNotificationDetails(
'batch_summary',
'Activity Summary',
channelDescription: 'Batched notification summaries',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
);
const notificationDetails = NotificationDetails(android: androidDetails);
try {
await _notifications.show(
id: 'batch_summary'.hashCode,
title: _l10n.notification_activityTitle,
body: parts.join(', '),
notificationDetails: notificationDetails,
payload: 'batch',
);
} catch (e) {
debugPrint('Failed to show batch summary notification: $e');
}
}
}
+34
View File
@@ -7,8 +7,42 @@ class StorageService {
static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _repeaterAutoClockSyncAfterLoginKey =
'repeater_auto_clock_sync_after_login';
static const String _deliveryObservationsKey = 'delivery_observations';
Future<Map<String, bool>> _loadRepeaterAutoClockSyncAfterLogin() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_repeaterAutoClockSyncAfterLoginKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value == true));
} catch (e) {
return {};
}
}
Future<bool> getRepeaterAutoClockSyncAfterLoginEnabled(
String repeaterPubKeyHex,
) async {
final settings = await _loadRepeaterAutoClockSyncAfterLogin();
return settings[repeaterPubKeyHex] ?? false;
}
Future<void> setRepeaterAutoClockSyncAfterLoginEnabled(
String repeaterPubKeyHex,
bool enabled,
) async {
final prefs = PrefsManager.instance;
final settings = await _loadRepeaterAutoClockSyncAfterLogin();
settings[repeaterPubKeyHex] = enabled;
final jsonStr = jsonEncode(settings);
await prefs.setString(_repeaterAutoClockSyncAfterLoginKey, jsonStr);
}
Future<void> savePathHistory(
String contactPubKeyHex,
ContactPathHistory history,
-33
View File
@@ -1,33 +0,0 @@
import 'prefs_manager.dart';
class LastDeviceStore {
static const _prefKeyLastDeviceId = 'bg_last_device_id';
static const _prefKeyLastDeviceName = 'bg_last_device_name';
Future<void> persistLastDevice(
String deviceId,
String deviceDisplayName,
) async {
final prefs = PrefsManager.instance;
await prefs.setString(_prefKeyLastDeviceId, deviceId);
await prefs.setString(_prefKeyLastDeviceName, deviceDisplayName);
}
String? getPersistedDeviceId() {
final prefs = PrefsManager.instance;
final deviceId = prefs.getString(_prefKeyLastDeviceId);
return deviceId;
}
String? getPersistedDeviceName() {
final prefs = PrefsManager.instance;
final displayName = prefs.getString(_prefKeyLastDeviceName);
return displayName;
}
Future<void> clearPersistedDevice() async {
final prefs = PrefsManager.instance;
await prefs.remove(_prefKeyLastDeviceId);
await prefs.remove(_prefKeyLastDeviceName);
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../helpers/utf8_length_limiter.dart';
/// A [TextField] that displays a live UTF-8 byte counter.
///
/// The counter appears below the field once the user starts typing and changes
/// colour as the limit is approached (orange at 70 %, error-red at 90 %).
///
/// All standard [TextField] behaviour (focus nodes, input actions, decoration
/// overrides, etc.) is forwarded so the widget can be dropped into any screen.
class ByteCountedTextField extends StatelessWidget {
/// Maximum number of UTF-8 bytes allowed.
final int maxBytes;
/// Controller for the text field.
final TextEditingController controller;
/// Optional focus node forwarded to the inner [TextField].
final FocusNode? focusNode;
/// Hint text shown when the field is empty.
final String? hintText;
/// Keyboard action button (defaults to [TextInputAction.send]).
final TextInputAction textInputAction;
/// Called when the user submits via the keyboard action button.
final ValueChanged<String>? onSubmitted;
/// Additional [TextInputFormatter]s applied *before* the byte limiter.
final List<TextInputFormatter> extraFormatters;
/// Text capitalisation forwarded to the inner [TextField].
final TextCapitalization textCapitalization;
/// Optional full [InputDecoration] override. When provided, [hintText] is
/// ignored set it inside the decoration instead.
final InputDecoration? decoration;
/// Ratio (01) at which the counter turns the warning colour (default 0.7).
final double warningThreshold;
/// Ratio (01) at which the counter turns the error colour (default 0.9).
final double errorThreshold;
/// Whether to hide the counter when the field is empty (default `true`).
final bool hideCounterWhenEmpty;
/// Optional encoder function to transform text before byte counting/limiting.
/// If provided, byte limits and counters will use the encoded text length.
final String Function(String)? encoder;
const ByteCountedTextField({
super.key,
required this.maxBytes,
required this.controller,
this.focusNode,
this.hintText,
this.textInputAction = TextInputAction.send,
this.onSubmitted,
this.extraFormatters = const [],
this.textCapitalization = TextCapitalization.sentences,
this.decoration,
this.warningThreshold = 0.7,
this.errorThreshold = 0.9,
this.hideCounterWhenEmpty = true,
this.encoder,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
final effectiveText = encoder != null
? encoder!(value.text)
: value.text;
final usedBytes = utf8.encode(effectiveText).length;
final ratio = maxBytes > 0 ? usedBytes / maxBytes : 0.0;
final showCounter = !(hideCounterWhenEmpty && value.text.isEmpty);
final counterColor = ratio > errorThreshold
? Theme.of(context).colorScheme.error
: ratio > warningThreshold
? Colors.orange
: Theme.of(context).colorScheme.onSurfaceVariant;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
maxLines: null,
controller: controller,
focusNode: focusNode,
inputFormatters: [
...extraFormatters,
Utf8LengthLimitingTextInputFormatter(
maxBytes,
encoder: encoder,
),
],
textCapitalization: textCapitalization,
decoration:
decoration ??
InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
textInputAction: textInputAction,
onSubmitted: onSubmitted,
),
if (showCounter)
Padding(
padding: const EdgeInsets.only(top: 4, right: 4),
child: Align(
alignment: Alignment.centerRight,
child: Text(
'$usedBytes / $maxBytes',
style: TextStyle(fontSize: 11, color: counterColor),
),
),
),
],
);
},
);
}
}
+13 -18
View File
@@ -11,6 +11,7 @@ import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../helpers/path_helper.dart';
import '../services/path_history_service.dart';
import '../helpers/snack_bar_builder.dart';
import 'path_selection_dialog.dart';
class PathManagementDialog {
@@ -65,11 +66,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
final l10n = context.l10n;
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
}
@@ -159,11 +159,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.chat_hopsCount(result.length)),
duration: const Duration(seconds: 2),
),
);
}
}
@@ -337,13 +336,12 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
return;
}
@@ -361,13 +359,12 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(
l10n.path_usingHopsPath(path.hopCount),
),
duration: const Duration(seconds: 2),
),
);
},
),
@@ -459,11 +456,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
onTap: () async {
await connector.clearContactPath(currentContact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
},
@@ -489,11 +485,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
},
+6 -9
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../helpers/snack_bar_builder.dart';
class PathSelectionDialog extends StatefulWidget {
final List<Contact> availableContacts;
@@ -138,26 +139,22 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
// Show error for invalid prefixes
if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.path_invalidHexPrefixes(invalidPrefixes.join(", ")),
),
showDismissibleSnackBar(
context,
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
// Check max path length (64 hops)
if (pathBytesList.length > 64) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(l10n.path_tooLong),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
+34 -6
View File
@@ -14,7 +14,7 @@ import 'path_management_dialog.dart';
class RepeaterLoginDialog extends StatefulWidget {
final Contact repeater;
final Function(String password) onLogin;
final Function(String password, bool isAdmin) onLogin;
const RepeaterLoginDialog({
super.key,
@@ -119,6 +119,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
: '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin');
bool? loginResult;
bool isAdmin = false;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
setState(() {
@@ -131,7 +132,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
);
await _connector.sendFrame(loginFrame);
loginResult = await _awaitLoginResponse(timeout);
(loginResult, isAdmin) = await _awaitLoginResponse(timeout);
if (loginResult == true) {
appLogger.info(
'Login succeeded for ${repeater.name}',
@@ -187,9 +188,32 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
}
final autoClockSync = await _storage
.getRepeaterAutoClockSyncAfterLoginEnabled(
widget.repeater.publicKeyHex,
);
if (autoClockSync) {
try {
final timestampSeconds =
DateTime.now().millisecondsSinceEpoch ~/ 1000;
await _connector.sendFrame(
buildSendCliCommandFrame(
repeater.publicKey,
'clock sync',
timestampSeconds: timestampSeconds,
),
);
} catch (e) {
appLogger.warn(
'Auto clock sync failed for ${repeater.name}: $e',
tag: 'RepeaterLogin',
);
}
}
if (mounted) {
Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password));
Future.microtask(() => widget.onLogin(password, isAdmin));
}
} catch (e) {
final repeater = _resolveRepeater(_connector);
@@ -206,17 +230,21 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
}
}
Future<bool?> _awaitLoginResponse(Duration timeout) async {
// _awaitLoginResponse returns a record of bool, for success and if the client is an admin
Future<(bool?, bool)> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>();
Timer? timer;
StreamSubscription<Uint8List>? subscription;
final targetPrefix = widget.repeater.publicKey.sublist(0, 6);
bool isAdmin = false;
subscription = _connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final code = frame[0];
if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return;
if (frame.length < 8) return;
// NOTE: a bug in the repeater firmware only ever sends 1 or 0 back, not the
// expected client permissions
isAdmin = (frame[1] == 1);
final prefix = frame.sublist(2, 8);
if (!listEquals(prefix, targetPrefix)) return;
@@ -235,7 +263,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
final result = await completer.future;
timer.cancel();
await subscription.cancel();
return result;
return (result, isAdmin);
}
@override
+13 -8
View File
@@ -10,11 +10,12 @@ import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
import '../helpers/snack_bar_builder.dart';
import 'path_management_dialog.dart';
class RoomLoginDialog extends StatefulWidget {
final Contact room;
final Function(String password) onLogin;
final Function(String password, bool isAdmin) onLogin;
const RoomLoginDialog({super.key, required this.room, required this.onLogin});
@@ -114,6 +115,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
: '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin');
bool? loginResult;
bool isAdmin = false;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
setState(() {
@@ -126,7 +128,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
);
await _connector.sendFrame(loginFrame);
loginResult = await _awaitLoginResponse(timeout);
(loginResult, isAdmin) = await _awaitLoginResponse(timeout);
if (loginResult == true) {
appLogger.info('Login succeeded for ${room.name}', tag: 'RoomLogin');
break;
@@ -166,7 +168,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
if (mounted) {
Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password));
Future.microtask(() => widget.onLogin(password, isAdmin));
}
} catch (e) {
final room = _resolveRepeater(_connector);
@@ -175,26 +177,29 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
setState(() {
_isLoggingIn = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
showDismissibleSnackBar(
context,
content: Text(context.l10n.login_failed(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
Future<bool?> _awaitLoginResponse(Duration timeout) async {
Future<(bool?, bool)> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>();
Timer? timer;
StreamSubscription<Uint8List>? subscription;
final targetPrefix = widget.room.publicKey.sublist(0, 6);
bool isAdmin = false;
subscription = _connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final code = frame[0];
if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return;
// NOTE: a bug in the repeater firmware only ever sends 1 or 0 back, not the
// expected client permissions
isAdmin = (frame[1] == 1);
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!listEquals(prefix, targetPrefix)) return;
@@ -214,7 +219,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
final result = await completer.future;
timer.cancel();
await subscription.cancel();
return result;
return (result, isAdmin);
}
@override
-6
View File
@@ -22,8 +22,6 @@ PODS:
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- flserial (from `Flutter/ephemeral/.symlinks/plugins/flserial/macos`)
@@ -36,7 +34,6 @@ DEPENDENCIES:
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
EXTERNAL SOURCES:
flserial:
@@ -59,8 +56,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
SPEC CHECKSUMS:
flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6
@@ -73,7 +68,6 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
+174 -358
View File
@@ -1,382 +1,198 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/widgets/adaptive_app_bar_title.dart';
import 'package:meshcore_open/screens/scanner_screen.dart';
import 'package:meshcore_open/screens/tcp_screen.dart';
import 'package:meshcore_open/services/app_settings_service.dart';
// ---------------------------------------------------------------------------
// Pure helpers extracted from TcpScreen logic so we can unit-test them
// without pumping the full screen widget tree.
// ---------------------------------------------------------------------------
class _FakeMeshCoreConnector extends MeshCoreConnector {
_FakeMeshCoreConnector();
/// Mirrors the validation in `_TcpScreenState._connectTcp`.
String? validateTcpInputs({required String host, required String portText}) {
if (host.trim().isEmpty) return 'hostRequired';
final parsed = int.tryParse(portText.trim());
if (parsed == null || parsed < 1 || parsed > 65535) return 'portInvalid';
return null;
}
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
String? initialEndpoint;
int connectTcpCalls = 0;
String? lastHost;
int? lastPort;
/// Mirrors `_TcpScreenState._buildStatusBar` text selection.
String tcpStatusText({
required MeshCoreConnectionState state,
required MeshCoreTransportType transport,
required bool isTcpConnected,
String? activeTcpEndpoint,
String connectingEndpoint = '',
required String notConnected,
required String Function(String) connectedTo,
required String Function(String) connectingTo,
required String disconnecting,
}) {
if (isTcpConnected) return connectedTo(activeTcpEndpoint ?? 'TCP');
if (state == MeshCoreConnectionState.connecting &&
transport == MeshCoreTransportType.tcp) {
return connectingTo(connectingEndpoint);
@override
MeshCoreConnectionState get state => initialState;
@override
MeshCoreTransportType get activeTransport => initialTransport;
@override
bool get isTcpTransportConnected =>
initialState == MeshCoreConnectionState.connected &&
initialTransport == MeshCoreTransportType.tcp;
@override
String? get activeTcpEndpoint => initialEndpoint;
@override
Future<void> connectTcp({required String host, required int port}) async {
connectTcpCalls += 1;
lastHost = host;
lastPort = port;
}
if (state == MeshCoreConnectionState.disconnecting &&
transport == MeshCoreTransportType.tcp) {
return disconnecting;
}
return notConnected;
}
/// Mirrors `_TcpScreenState._friendlyErrorMessage`.
String tcpFriendlyError({
required Object error,
required String unsupported,
required String timedOut,
required String Function(String) connectionFailed,
Widget _buildTestApp({
required MeshCoreConnector connector,
required Widget child,
Locale? locale,
}) {
if (error is UnsupportedError) return unsupported;
if (error is TimeoutException) return timedOut;
if (error is StateError) return connectionFailed(error.message);
if (error is ArgumentError) {
return connectionFailed(error.message?.toString() ?? error.toString());
}
return connectionFailed(error.toString());
}
/// Whether the connect button should be disabled.
bool isTcpConnectButtonDisabled({
required MeshCoreConnectionState state,
required MeshCoreTransportType transport,
}) {
final isConnecting =
state == MeshCoreConnectionState.connecting &&
transport == MeshCoreTransportType.tcp;
return isConnecting || state == MeshCoreConnectionState.scanning;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// -- Validation -----------------------------------------------------------
group('TCP input validation', () {
test('empty host returns hostRequired', () {
expect(validateTcpInputs(host: '', portText: '5000'), 'hostRequired');
});
test('whitespace-only host returns hostRequired', () {
expect(validateTcpInputs(host: ' ', portText: '5000'), 'hostRequired');
});
test('non-numeric port returns portInvalid', () {
expect(
validateTcpInputs(host: '192.168.1.50', portText: 'abc'),
'portInvalid',
);
});
test('port 0 returns portInvalid', () {
expect(
validateTcpInputs(host: '192.168.1.50', portText: '0'),
'portInvalid',
);
});
test('port > 65535 returns portInvalid', () {
expect(
validateTcpInputs(host: '192.168.1.50', portText: '99999'),
'portInvalid',
);
});
test('valid host and port returns null', () {
expect(validateTcpInputs(host: '192.168.1.50', portText: '5000'), isNull);
});
test('port 1 is valid (lower boundary)', () {
expect(validateTcpInputs(host: 'h', portText: '1'), isNull);
});
test('port 65535 is valid (upper boundary)', () {
expect(validateTcpInputs(host: 'h', portText: '65535'), isNull);
});
});
// -- Status text ----------------------------------------------------------
group('TCP status text', () {
String status({
MeshCoreConnectionState state = MeshCoreConnectionState.disconnected,
MeshCoreTransportType transport = MeshCoreTransportType.tcp,
bool isTcpConnected = false,
String? activeTcpEndpoint,
String connectingEndpoint = 'host:5000',
}) => tcpStatusText(
state: state,
transport: transport,
isTcpConnected: isTcpConnected,
activeTcpEndpoint: activeTcpEndpoint,
connectingEndpoint: connectingEndpoint,
notConnected: 'NOT_CONNECTED',
connectedTo: (ep) => 'CONNECTED:$ep',
connectingTo: (ep) => 'CONNECTING:$ep',
disconnecting: 'DISCONNECTING',
);
test('disconnected shows not-connected', () {
expect(status(), 'NOT_CONNECTED');
});
test('connected with endpoint', () {
expect(
status(
state: MeshCoreConnectionState.connected,
isTcpConnected: true,
activeTcpEndpoint: 'server.local:5000',
),
'CONNECTED:server.local:5000',
);
});
test('connected with null endpoint falls back to TCP', () {
expect(
status(state: MeshCoreConnectionState.connected, isTcpConnected: true),
'CONNECTED:TCP',
);
});
test('connecting over TCP shows connecting-to', () {
expect(
status(
state: MeshCoreConnectionState.connecting,
connectingEndpoint: '10.0.0.1:4000',
),
'CONNECTING:10.0.0.1:4000',
);
});
test('disconnecting over TCP shows disconnecting', () {
expect(
status(state: MeshCoreConnectionState.disconnecting),
'DISCONNECTING',
);
});
test('connecting over bluetooth falls through to not-connected', () {
expect(
status(
state: MeshCoreConnectionState.connecting,
transport: MeshCoreTransportType.bluetooth,
),
'NOT_CONNECTED',
);
});
});
// -- Error mapping --------------------------------------------------------
group('TCP friendly error messages', () {
String error(Object e) => tcpFriendlyError(
error: e,
unsupported: 'UNSUPPORTED',
timedOut: 'TIMED_OUT',
connectionFailed: (msg) => 'FAILED:$msg',
);
test('UnsupportedError → unsupported', () {
expect(error(UnsupportedError('nope')), 'UNSUPPORTED');
});
test('TimeoutException → timedOut', () {
expect(error(TimeoutException('slow')), 'TIMED_OUT');
});
test('StateError → connectionFailed with message', () {
expect(error(StateError('refused')), 'FAILED:refused');
});
test('ArgumentError → connectionFailed with message', () {
expect(error(ArgumentError('bad host')), 'FAILED:bad host');
});
test('generic error → connectionFailed with toString', () {
expect(error(Exception('boom')), 'FAILED:Exception: boom');
});
});
// -- Button disabled state ------------------------------------------------
group('TCP connect button disabled state', () {
test('disabled while scanning', () {
expect(
isTcpConnectButtonDisabled(
state: MeshCoreConnectionState.scanning,
transport: MeshCoreTransportType.bluetooth,
),
isTrue,
);
});
test('disabled while connecting over TCP', () {
expect(
isTcpConnectButtonDisabled(
state: MeshCoreConnectionState.connecting,
transport: MeshCoreTransportType.tcp,
),
isTrue,
);
});
test('enabled while connecting over bluetooth (not TCP-specific)', () {
expect(
isTcpConnectButtonDisabled(
state: MeshCoreConnectionState.connecting,
transport: MeshCoreTransportType.bluetooth,
),
isFalse,
);
});
test('enabled when disconnected', () {
expect(
isTcpConnectButtonDisabled(
state: MeshCoreConnectionState.disconnected,
transport: MeshCoreTransportType.tcp,
),
isFalse,
);
});
});
// -- Localized strings resolve correctly ----------------------------------
testWidgets('English TCP localizations resolve without error', (
tester,
) async {
late AppLocalizations l10n;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) {
l10n = AppLocalizations.of(context);
return const SizedBox.shrink();
},
),
),
);
await tester.pumpAndSettle();
expect(l10n.tcpScreenTitle, isNotEmpty);
expect(l10n.tcpHostLabel, isNotEmpty);
expect(l10n.tcpPortLabel, isNotEmpty);
expect(l10n.tcpStatus_notConnected, isNotEmpty);
expect(l10n.tcpErrorHostRequired, isNotEmpty);
expect(l10n.tcpErrorPortInvalid, isNotEmpty);
expect(l10n.tcpErrorUnsupported, isNotEmpty);
expect(l10n.tcpErrorTimedOut, isNotEmpty);
expect(l10n.tcpConnectionFailed('x'), contains('x'));
expect(l10n.tcpStatus_connectingTo('host:5000'), contains('host:5000'));
expect(l10n.scanner_connectedTo('device'), contains('device'));
});
// -- Isolated widget: AdaptiveAppBarTitle overflow ------------------------
testWidgets('AdaptiveAppBarTitle does not overflow with long text', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 100));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(
width: 200,
child: AdaptiveAppBarTitle(
'This is a very long title that would normally overflow',
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.text('This is a very long title that would normally overflow'),
findsOneWidget,
);
});
// -- Isolated widget: status bar Row with FittedBox overflow --------------
testWidgets('Status bar row with long text does not overflow at 320px', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 100));
addTearDown(() => tester.binding.setSurfaceSize(null));
const longText =
'Connected to meshcore-room-server-very-long-hostname.local:5000';
const statusColor = Colors.green;
// Exact widget tree from _buildStatusBar in TcpScreen / UsbScreen.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
const Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
longText,
style: const TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
return MultiProvider(
providers: [
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector),
ChangeNotifierProvider<AppSettingsService>(
create: (_) => AppSettingsService(),
),
],
child: MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
}
void main() {
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect(find.text(l10n.tcpScreenTitle), findsOneWidget);
expect(find.text(l10n.tcpHostLabel), findsOneWidget);
expect(find.text(l10n.tcpPortLabel), findsOneWidget);
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget);
});
testWidgets('TcpScreen validation errors are localized', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
await tester.enterText(find.byType(TextField).first, '');
await tester.tap(find.byKey(const Key('tcp_connect_button')));
await tester.pumpAndSettle();
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
expect(connector.connectTcpCalls, 0);
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
await tester.enterText(find.byType(TextField).at(1), '99999');
await tester.tap(find.byKey(const Key('tcp_connect_button')));
await tester.pumpAndSettle();
expect(connector.connectTcpCalls, 0);
});
testWidgets('TCP Bluetooth action returns to existing scanner route', (
tester,
) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
await tester.pumpAndSettle();
expect(find.byType(TcpScreen), findsOneWidget);
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
await tester.pumpAndSettle();
expect(find.byType(TcpScreen), findsNothing);
expect(find.byType(ScannerScreen), findsOneWidget);
final navigatorState = tester.state<NavigatorState>(find.byType(Navigator));
expect(navigatorState.canPop(), isFalse);
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
// Drain that debounce timer before test teardown.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
testWidgets('TcpScreen disables connect button while connector is scanning', (
tester,
) async {
final connector = _FakeMeshCoreConnector()
..initialState = MeshCoreConnectionState.scanning;
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final button = tester.widget<ButtonStyleButton>(
find.byKey(const Key('tcp_connect_button')),
);
expect(button.onPressed, isNull);
expect(connector.connectTcpCalls, 0);
});
testWidgets('TcpScreen narrow width long status text does not overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final connector = _FakeMeshCoreConnector()
..initialState = MeshCoreConnectionState.connected
..initialTransport = MeshCoreTransportType.tcp
..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000';
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text(longText), findsOneWidget);
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect(
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
}
+221 -529
View File
@@ -1,584 +1,276 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/utils/usb_port_labels.dart';
import 'package:meshcore_open/screens/scanner_screen.dart';
import 'package:meshcore_open/screens/usb_screen.dart';
import 'package:meshcore_open/utils/platform_info.dart';
// ---------------------------------------------------------------------------
// Pure helpers extracted from UsbScreen logic.
// ---------------------------------------------------------------------------
class _FakeMeshCoreConnector extends MeshCoreConnector {
_FakeMeshCoreConnector({
this.initialState = MeshCoreConnectionState.disconnected,
List<String>? ports,
}) : _ports = ports ?? <String>[];
/// Mirrors `_UsbScreenState._buildStatusBar` text selection.
///
/// [isLoadingPorts] corresponds to the screen's `_isLoadingPorts` flag.
String usbStatusText({
required bool isLoadingPorts,
required bool isUsbTransportConnected,
required MeshCoreConnectionState state,
required MeshCoreTransportType transport,
String? activeUsbPortDisplayLabel,
// L10n strings passed directly so we don't need BuildContext.
required String searching,
required String Function(String) connectedTo,
required String disconnecting,
required String connecting,
required String notConnected,
final MeshCoreConnectionState initialState;
final List<String> _ports;
String? requestPortLabel;
String? fallbackDeviceName;
int connectUsbCalls = 0;
String? lastConnectPortName;
String? fakeActiveUsbPort;
String? fakeActiveUsbPortDisplayLabel;
bool fakeUsbTransportConnected = false;
Future<List<String>> Function()? listUsbPortsImpl;
Future<void> Function({required String portName})? connectUsbImpl;
@override
MeshCoreConnectionState get state => initialState;
@override
MeshCoreTransportType get activeTransport => MeshCoreTransportType.usb;
@override
String? get activeUsbPort => fakeActiveUsbPort;
@override
String? get activeUsbPortDisplayLabel =>
fakeActiveUsbPortDisplayLabel ?? fakeActiveUsbPort;
@override
bool get isUsbTransportConnected => fakeUsbTransportConnected;
@override
Future<List<String>> listUsbPorts() async {
if (listUsbPortsImpl != null) {
return listUsbPortsImpl!();
}
return List<String>.from(_ports);
}
@override
Future<void> connectUsb({
required String portName,
int baudRate = 115200,
}) async {
if (connectUsbImpl != null) {
return connectUsbImpl!(portName: portName);
}
connectUsbCalls += 1;
lastConnectPortName = portName;
}
@override
void setUsbRequestPortLabel(String label) {
requestPortLabel = label;
}
@override
void setUsbFallbackDeviceName(String label) {
fallbackDeviceName = label;
}
}
Widget _buildTestApp({
required MeshCoreConnector connector,
required Widget child,
}) {
if (isLoadingPorts) return searching;
if (isUsbTransportConnected) {
switch (state) {
case MeshCoreConnectionState.connected:
return connectedTo(activeUsbPortDisplayLabel ?? 'USB');
case MeshCoreConnectionState.disconnecting:
return disconnecting;
default:
return notConnected;
}
}
if (state == MeshCoreConnectionState.connecting &&
transport == MeshCoreTransportType.usb) {
return connecting;
}
return notConnected;
}
/// Mirrors `_UsbScreenState._friendlyErrorMessage`.
///
/// Uses string keys instead of l10n objects so this is a pure function.
String usbFriendlyErrorKey(Object error) {
if (error is PlatformException) {
switch (error.code) {
case 'usb_permission_denied':
return 'permissionDenied';
case 'usb_device_missing':
case 'usb_device_detached':
return 'deviceMissing';
case 'usb_invalid_port':
return 'invalidPort';
case 'usb_busy':
return 'busy';
case 'usb_not_connected':
return 'notConnected';
case 'usb_open_failed':
case 'usb_driver_missing':
return 'openFailed';
case 'usb_connect_failed':
return 'connectFailed';
}
}
if (error is UnsupportedError) return 'unsupported';
if (error is StateError) {
final msg = error.message;
if (msg.contains('already active')) return 'alreadyActive';
if (msg.contains('No USB serial device selected')) {
return 'noDeviceSelected';
}
if (msg.contains('not open') || msg.contains('closed')) {
return 'portClosed';
}
if (msg.contains('Timed out')) return 'connectTimedOut';
if (msg.contains('Failed to open')) return 'openFailed';
}
if (error is TimeoutException) return 'connectTimedOut';
return 'unknown';
}
/// Mirrors the guard in `_UsbScreenState._connectPort`:
/// returns true only when the connector is disconnected.
bool shouldAllowUsbConnect(MeshCoreConnectionState state) =>
state == MeshCoreConnectionState.disconnected;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// -- Port name helpers (normalizeUsbPortName / friendlyUsbPortName) -------
group('USB port name parsing', () {
test('normalizeUsbPortName extracts raw port before separator', () {
expect(normalizeUsbPortName('COM6 - USB Serial Device (COM6)'), 'COM6');
});
test('normalizeUsbPortName returns input when no separator', () {
expect(normalizeUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0');
});
test('normalizeUsbPortName trims whitespace', () {
expect(normalizeUsbPortName(' COM3 '), 'COM3');
});
test('friendlyUsbPortName extracts description field', () {
expect(
friendlyUsbPortName('COM6 - USB Serial Device (COM6) - HWID'),
'USB Serial Device (COM6)',
);
});
test(
'friendlyUsbPortName falls back to raw name if description is n/a',
() {
expect(friendlyUsbPortName('COM6 - n/a'), 'COM6');
},
);
test('friendlyUsbPortName falls back when only one part', () {
expect(friendlyUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0');
});
});
// -- Connect guard --------------------------------------------------------
group('USB connect guard', () {
test('allows connect when disconnected', () {
expect(
shouldAllowUsbConnect(MeshCoreConnectionState.disconnected),
isTrue,
);
});
test('blocks connect when connected', () {
expect(shouldAllowUsbConnect(MeshCoreConnectionState.connected), isFalse);
});
test('blocks connect when connecting', () {
expect(
shouldAllowUsbConnect(MeshCoreConnectionState.connecting),
isFalse,
);
});
test('blocks connect when scanning', () {
expect(shouldAllowUsbConnect(MeshCoreConnectionState.scanning), isFalse);
});
test('blocks connect when disconnecting', () {
expect(
shouldAllowUsbConnect(MeshCoreConnectionState.disconnecting),
isFalse,
);
});
});
// -- Status text ----------------------------------------------------------
group('USB status text', () {
String status({
bool isLoadingPorts = false,
bool isUsbTransportConnected = false,
MeshCoreConnectionState state = MeshCoreConnectionState.disconnected,
MeshCoreTransportType transport = MeshCoreTransportType.usb,
String? activeUsbPortDisplayLabel,
}) => usbStatusText(
isLoadingPorts: isLoadingPorts,
isUsbTransportConnected: isUsbTransportConnected,
state: state,
transport: transport,
activeUsbPortDisplayLabel: activeUsbPortDisplayLabel,
searching: 'SEARCHING',
connectedTo: (label) => 'CONNECTED:$label',
disconnecting: 'DISCONNECTING',
connecting: 'CONNECTING',
notConnected: 'NOT_CONNECTED',
);
test('loading ports shows searching', () {
expect(status(isLoadingPorts: true), 'SEARCHING');
});
test('connected USB with label', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.connected,
activeUsbPortDisplayLabel: 'COM6 - Device',
),
'CONNECTED:COM6 - Device',
);
});
test('connected USB with null label falls back to USB', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.connected,
),
'CONNECTED:USB',
);
});
test('USB transport connected but disconnecting', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.disconnecting,
),
'DISCONNECTING',
);
});
test('USB transport connected but scanning falls to default', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.scanning,
),
'NOT_CONNECTED',
);
});
test('connecting over USB shows connecting', () {
expect(status(state: MeshCoreConnectionState.connecting), 'CONNECTING');
});
test('connecting over bluetooth falls through to not-connected', () {
expect(
status(
state: MeshCoreConnectionState.connecting,
transport: MeshCoreTransportType.bluetooth,
),
'NOT_CONNECTED',
);
});
test('disconnected shows not-connected', () {
expect(status(), 'NOT_CONNECTED');
});
});
// -- Error mapping --------------------------------------------------------
group('USB friendly error mapping', () {
test('PlatformException usb_permission_denied', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_permission_denied')),
'permissionDenied',
);
});
test('PlatformException usb_device_missing', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_device_missing')),
'deviceMissing',
);
});
test('PlatformException usb_device_detached', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_device_detached')),
'deviceMissing',
);
});
test('PlatformException usb_invalid_port', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_invalid_port')),
'invalidPort',
);
});
test('PlatformException usb_busy', () {
expect(usbFriendlyErrorKey(PlatformException(code: 'usb_busy')), 'busy');
});
test('PlatformException usb_not_connected', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_not_connected')),
'notConnected',
);
});
test('PlatformException usb_open_failed', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_open_failed')),
'openFailed',
);
});
test('PlatformException usb_driver_missing', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_driver_missing')),
'openFailed',
);
});
test('PlatformException usb_connect_failed', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_connect_failed')),
'connectFailed',
);
});
test('PlatformException with unknown code falls through', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_whatever')),
'unknown',
);
});
test('UnsupportedError → unsupported', () {
expect(usbFriendlyErrorKey(UnsupportedError('nope')), 'unsupported');
});
test('StateError "already active" → alreadyActive', () {
expect(
usbFriendlyErrorKey(StateError('already active')),
'alreadyActive',
);
});
test('StateError "No USB serial device selected" → noDeviceSelected', () {
expect(
usbFriendlyErrorKey(StateError('No USB serial device selected')),
'noDeviceSelected',
);
});
test('StateError "not open" → portClosed', () {
expect(usbFriendlyErrorKey(StateError('port not open')), 'portClosed');
});
test('StateError "closed" → portClosed', () {
expect(
usbFriendlyErrorKey(StateError('connection closed')),
'portClosed',
);
});
test('StateError "Timed out" → connectTimedOut', () {
expect(
usbFriendlyErrorKey(StateError('Timed out waiting')),
'connectTimedOut',
);
});
test('StateError "Failed to open" → openFailed', () {
expect(
usbFriendlyErrorKey(StateError('Failed to open device')),
'openFailed',
);
});
test('TimeoutException → connectTimedOut', () {
expect(usbFriendlyErrorKey(TimeoutException('slow')), 'connectTimedOut');
});
test('generic error → unknown', () {
expect(usbFriendlyErrorKey(Exception('boom')), 'unknown');
});
});
// -- Localized strings resolve correctly ----------------------------------
testWidgets('English USB localizations resolve without error', (
tester,
) async {
late AppLocalizations l10n;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
return ChangeNotifierProvider<MeshCoreConnector>.value(
value: connector,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) {
l10n = AppLocalizations.of(context);
return const SizedBox.shrink();
home: child,
),
);
}
void main() {
testWidgets('UsbScreen passes localized chooser label to connector', (
tester,
) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(connector.requestPortLabel, 'Select a USB device');
});
testWidgets(
'UsbScreen does not call connectUsb when connector is not disconnected',
(tester) async {
final connector = _FakeMeshCoreConnector(
initialState: MeshCoreConnectionState.connected,
ports: <String>['COM6 - USB Serial Device (COM6)'],
);
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ListTile).first);
await tester.pump();
expect(connector.connectUsbCalls, 0);
// UsbScreen.dispose() schedules disconnect work that debounces notify.
// Drain that debounce timer before test teardown.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
},
),
),
);
testWidgets('UsbScreen sends raw port name when tapping Connect', (
tester,
) async {
final connector = _FakeMeshCoreConnector(
ports: <String>['COM6 - USB Serial Device (COM6)'],
);
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(l10n.usbScreenTitle, isNotEmpty);
expect(l10n.usbScreenStatus, 'Select a USB device');
expect(l10n.usbStatus_notConnected, isNotEmpty);
expect(l10n.usbStatus_connecting, isNotEmpty);
expect(l10n.usbStatus_searching, isNotEmpty);
expect(l10n.usbErrorPermissionDenied, isNotEmpty);
expect(l10n.usbErrorDeviceMissing, isNotEmpty);
expect(l10n.usbErrorInvalidPort, isNotEmpty);
expect(l10n.usbErrorBusy, isNotEmpty);
expect(l10n.usbErrorNotConnected, isNotEmpty);
expect(l10n.usbErrorOpenFailed, isNotEmpty);
expect(l10n.usbErrorConnectFailed, isNotEmpty);
expect(l10n.usbErrorUnsupported, isNotEmpty);
expect(l10n.usbErrorAlreadyActive, isNotEmpty);
expect(l10n.usbErrorNoDeviceSelected, isNotEmpty);
expect(l10n.usbErrorPortClosed, isNotEmpty);
expect(l10n.usbErrorConnectTimedOut, isNotEmpty);
expect(l10n.scanner_connectedTo('device'), contains('device'));
expect(l10n.scanner_disconnecting, isNotEmpty);
await tester.tap(find.byType(ListTile).first);
await tester.pump();
expect(connector.connectUsbCalls, 1);
expect(connector.lastConnectPortName, 'COM6');
});
// -- Isolated widget: status bar Row with FittedBox overflow --------------
testWidgets('USB status bar with long text does not overflow at 320px', (
testWidgets('ScannerScreen USB action reflects platform support', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 100));
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
if (PlatformInfo.supportsUsbSerial) {
expect(find.widgetWithText(FloatingActionButton, 'USB'), findsOneWidget);
} else {
expect(find.widgetWithText(FloatingActionButton, 'USB'), findsNothing);
}
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
// Drain that debounce timer before test teardown.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
testWidgets('ScannerScreen narrow width keeps actions without overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
const longText =
'Connected to /dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
const statusColor = Colors.green;
final connector = _FakeMeshCoreConnector();
// Exact widget tree from _buildStatusBar in UsbScreen.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
const Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
longText,
style: const TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
),
),
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text(longText), findsOneWidget);
final context = tester.element(find.byType(ScannerScreen));
final l10n = AppLocalizations.of(context);
expect(find.text(l10n.scanner_scan), findsOneWidget);
if (PlatformInfo.supportsUsbSerial) {
expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget);
}
if (!PlatformInfo.isWeb) {
expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget);
}
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
// -- Isolated widget: bottom nav FittedBox overflow -----------------------
testWidgets('Bottom nav row with multiple FABs does not overflow at 320px', (
testWidgets('UsbScreen narrow width long status text does not overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 200));
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Mirrors the bottomNavigationBar structure from ScannerScreen / UsbScreen
// with all possible buttons visible.
final connector =
_FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected)
..fakeUsbTransportConnected = true
..fakeActiveUsbPortDisplayLabel =
'/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: const SizedBox.expand(),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton.extended(
onPressed: () {},
heroTag: 'usb',
icon: const Icon(Icons.usb),
label: const Text('USB'),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {},
heroTag: 'tcp',
icon: const Icon(Icons.lan),
label: const Text('TCP'),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {},
heroTag: 'ble',
icon: const Icon(Icons.bluetooth_searching),
label: const Text('Scan'),
),
],
),
),
),
),
),
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('USB'), findsOneWidget);
expect(find.text('TCP'), findsOneWidget);
expect(find.text('Scan'), findsOneWidget);
});
// -- describeWebUsbPort ---------------------------------------------------
group('describeWebUsbPort', () {
test('null vendor and product returns requestPortLabel', () {
final context = tester.element(find.byType(UsbScreen));
final l10n = AppLocalizations.of(context);
expect(
describeWebUsbPort(vendorId: null, productId: null),
'Choose USB Device',
);
});
test('known VID:PID uses knownUsbNames', () {
expect(
describeWebUsbPort(
vendorId: 0x1A86,
productId: 0x7523,
knownUsbNames: {'1a86:7523': 'CH340 Serial'},
find.text(
l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!),
),
'CH340 Serial (VID:1A86 PID:7523)',
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
test('unknown VID:PID uses fallback device name', () {
expect(
describeWebUsbPort(
vendorId: 0x1234,
productId: 0x5678,
fallbackDeviceName: 'My Device',
),
'My Device (VID:1234 PID:5678)',
group('Error Handling', () {
testWidgets('shows error SnackBar when listing ports fails', (
tester,
) async {
final connector = _FakeMeshCoreConnector();
connector.listUsbPortsImpl = () async {
throw PlatformException(
code: 'usb_permission_denied',
message: 'Permission denied',
);
});
});
};
// -- buildUsbDisplayLabel -------------------------------------------------
group('buildUsbDisplayLabel', () {
test('appends device name when present', () {
expect(
buildUsbDisplayLabel(
basePortLabel: 'COM6',
deviceName: 'MeshCore Node',
),
'COM6 - MeshCore Node',
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(find.text('USB permission was denied.'), findsOneWidget);
});
test('returns base label when device name is null', () {
expect(
buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: null),
'COM6',
testWidgets('connection failure shows SnackBar error', (tester) async {
final connector = _FakeMeshCoreConnector(ports: <String>['COM1']);
var connectAttempted = false;
connector.connectUsbImpl = ({required String portName}) async {
connectAttempted = true;
throw PlatformException(code: 'usb_busy', message: 'Device is busy');
};
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
});
await tester.pumpAndSettle();
test('returns base label when device name is whitespace', () {
await tester.tap(find.byType(ListTile).first);
await tester.pumpAndSettle();
expect(connectAttempted, isTrue);
expect(
buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: ' '),
'COM6',
find.text('Another USB connection request is already in progress.'),
findsOneWidget,
);
});
});
+1 -69
View File
@@ -1,69 +1 @@
{
"bg": [
"chat_sendMessage"
],
"de": [
"chat_sendMessage"
],
"es": [
"chat_sendMessage"
],
"fr": [
"chat_sendMessage"
],
"hu": [
"chat_sendMessage"
],
"it": [
"chat_sendMessage"
],
"ja": [
"chat_sendMessage"
],
"ko": [
"chat_sendMessage"
],
"nl": [
"chat_sendMessage"
],
"pl": [
"chat_sendMessage"
],
"pt": [
"chat_sendMessage"
],
"ru": [
"chat_sendMessage"
],
"sk": [
"chat_sendMessage"
],
"sl": [
"chat_sendMessage"
],
"sv": [
"chat_sendMessage"
],
"uk": [
"chat_sendMessage"
],
"zh": [
"chat_sendMessage"
]
}
{}