Compare commits

..

1 Commits

Author SHA1 Message Date
Enot (ded) Skelly d529ce9228 fix foreground service and add notification nav
wraps MaterialApp in WithForegroundService to keep alive when swiped away

persists last connected device and clears on manual disconnect to allow
reconnect after kill

added lifecycle tracking to iOS and keep android notification alive with
heartbeat

add notification navigation

change screen tests to be less brittle

address PR commnets
2026-04-13 08:09:22 -07:00
76 changed files with 2201 additions and 1900 deletions
+70
View File
@@ -40,6 +40,7 @@ import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart'; import '../storage/contact_store.dart';
import '../storage/message_store.dart'; import '../storage/message_store.dart';
import '../storage/unread_store.dart'; import '../storage/unread_store.dart';
import '../storage/last_device_store.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/battery_utils.dart'; import '../utils/battery_utils.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
@@ -281,6 +282,7 @@ class MeshCoreConnector extends ChangeNotifier {
final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore(); final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore();
final ChannelStore _channelStore = ChannelStore(); final ChannelStore _channelStore = ChannelStore();
final UnreadStore _unreadStore = UnreadStore(); final UnreadStore _unreadStore = UnreadStore();
final LastDeviceStore _lastDeviceStore = LastDeviceStore();
List<Channel> _cachedChannels = []; List<Channel> _cachedChannels = [];
final Map<int, bool> _channelSmazEnabled = {}; final Map<int, bool> _channelSmazEnabled = {};
bool _lastSentWasCliCommand = bool _lastSentWasCliCommand =
@@ -768,6 +770,10 @@ class MeshCoreConnector extends ChangeNotifier {
_appDebugLogService = appDebugLogService; _appDebugLogService = appDebugLogService;
_backgroundService = backgroundService; _backgroundService = backgroundService;
_timeoutPredictionService = timeoutPredictionService; _timeoutPredictionService = timeoutPredictionService;
// When the app resumes from background, check if we need to reconnect.
_backgroundService?.onResume = _onAppResumed;
_usbManager.setDebugLogService(_appDebugLogService); _usbManager.setDebugLogService(_appDebugLogService);
_tcpConnector.setDebugLogService(_appDebugLogService); _tcpConnector.setDebugLogService(_appDebugLogService);
@@ -1879,6 +1885,7 @@ class MeshCoreConnector extends ChangeNotifier {
); );
_setState(MeshCoreConnectionState.connected); _setState(MeshCoreConnectionState.connected);
_lastDeviceStore.persistLastDevice(_deviceId!, _deviceDisplayName!);
if (_shouldGateInitialChannelSync) { if (_shouldGateInitialChannelSync) {
_hasReceivedDeviceInfo = false; _hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = true; _pendingInitialChannelSync = true;
@@ -2225,6 +2232,56 @@ 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({ Future<void> disconnect({
bool manual = true, bool manual = true,
bool skipBleDeviceDisconnect = false, bool skipBleDeviceDisconnect = false,
@@ -2245,6 +2302,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (manual) { if (manual) {
_manualDisconnect = true; _manualDisconnect = true;
_cancelReconnectTimer(); _cancelReconnectTimer();
_lastDeviceStore.clearPersistedDevice();
_notificationService.cancelAll();
unawaited(_backgroundService?.stop()); unawaited(_backgroundService?.stop());
} else { } else {
_manualDisconnect = false; _manualDisconnect = false;
@@ -4910,6 +4969,17 @@ 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( void _maybeIncrementChannelUnread(
ChannelMessage message, { ChannelMessage message, {
required bool isNew, required bool isNew,
+6 -5
View File
@@ -3,7 +3,6 @@ import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../helpers/snack_bar_builder.dart';
class LinkHandler { class LinkHandler {
static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) { static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) {
@@ -94,19 +93,21 @@ class LinkHandler {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.chat_couldNotOpenLink(url)), content: Text(context.l10n.chat_couldNotOpenLink(url)),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.chat_invalidLink), content: Text(context.l10n.chat_invalidLink),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
-56
View File
@@ -1,56 +0,0 @@
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,
),
);
}
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingHidePin": "Скриване на PIN кода", "scanner_linuxPairingHidePin": "Скриване на PIN кода",
"scanner_linuxPairingShowPin": "Покажи PIN", "scanner_linuxPairingShowPin": "Покажи PIN",
"repeater_cliQuickClockSync": "Синхронизация на часовника", "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": "Инструменти за гости"
} }
+1 -13
View File
@@ -2089,17 +2089,5 @@
"scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN", "scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN",
"scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine).", "scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine).",
"repeater_cliQuickClockSync": "Uhr Synchronisieren", "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"
} }
+2 -13
View File
@@ -1038,8 +1038,8 @@
"login_enterPassword": "Enter password", "login_enterPassword": "Enter password",
"login_savePassword": "Save password", "login_savePassword": "Save password",
"login_savePasswordSubtitle": "Password will be stored securely on this device", "login_savePasswordSubtitle": "Password will be stored securely on this device",
"login_repeaterDescription": "Enter the repeater password for guest or admin access.", "login_repeaterDescription": "Enter the repeater password to access settings and status.",
"login_roomDescription": "Enter the room password for guest or admin access.", "login_roomDescription": "Enter the room password to access settings and status.",
"login_routing": "Routing", "login_routing": "Routing",
"login_routingMode": "Routing mode", "login_routingMode": "Routing mode",
"login_autoUseSavedPath": "Auto (use saved path)", "login_autoUseSavedPath": "Auto (use saved path)",
@@ -1105,10 +1105,7 @@
"path_setPath": "Set Path", "path_setPath": "Set Path",
"repeater_management": "Repeater Management", "repeater_management": "Repeater Management",
"room_management": "Room Server Management", "room_management": "Room Server Management",
"repeater_guest": "Repeater Information",
"room_guest": "Room Server Information",
"repeater_managementTools": "Management Tools", "repeater_managementTools": "Management Tools",
"repeater_guestTools": "Guest Tools",
"repeater_status": "Status", "repeater_status": "Status",
"repeater_statusSubtitle": "View repeater status, stats, and neighbors", "repeater_statusSubtitle": "View repeater status, stats, and neighbors",
"repeater_telemetry": "Telemetry", "repeater_telemetry": "Telemetry",
@@ -1119,14 +1116,6 @@
"repeater_neighborsSubtitle": "View zero hop neighbors.", "repeater_neighborsSubtitle": "View zero hop neighbors.",
"repeater_settings": "Settings", "repeater_settings": "Settings",
"repeater_settingsSubtitle": "Configure repeater parameters", "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_statusTitle": "Repeater Status",
"repeater_routingMode": "Routing mode", "repeater_routingMode": "Routing mode",
"repeater_autoUseSavedPath": "Auto (use saved path)", "repeater_autoUseSavedPath": "Auto (use saved path)",
+1 -13
View File
@@ -2089,17 +2089,5 @@
"translation_translationOptions": "Opciones de traducción", "translation_translationOptions": "Opciones de traducción",
"translation_systemLanguage": "Idioma del sistema", "translation_systemLanguage": "Idioma del sistema",
"repeater_cliQuickDiscovery": "Descubrir Vecinos", "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"
} }
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si nécessaire).", "scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si nécessaire).",
"scanner_linuxPairingShowPin": "Afficher le code PIN", "scanner_linuxPairingShowPin": "Afficher le code PIN",
"repeater_cliQuickClockSync": "Synchronisation de l'horloge", "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"
} }
+3 -14
View File
@@ -2081,7 +2081,7 @@
} }
}, },
"scanner_linuxPairingShowPin": "Megjelenítse a PIN-kódot", "scanner_linuxPairingShowPin": "Megjelenítse a PIN-kódot",
"scanner_linuxPairingPinPrompt": "Adja meg a(z) {deviceName} PIN-kódját (hagyja üresen, ha nincs).", "scanner_linuxPairingPinPrompt": "Adja meg a PIN kódot a {deviceName} számára (hagyja üresen, ha nincs).",
"scanner_linuxPairingHidePin": "Rejtse el a PIN-kódot", "scanner_linuxPairingHidePin": "Rejtse el a PIN-kódot",
"scanner_linuxPairingPinTitle": "Bluetooth párosítási PIN", "scanner_linuxPairingPinTitle": "Bluetooth párosítási PIN",
"@translation_translateTo": { "@translation_translateTo": {
@@ -2098,18 +2098,7 @@
"translation_translateTo": "Fordítás {language}-ra", "translation_translateTo": "Fordítás {language}-ra",
"translation_translationOptions": "Fordítási lehetőségek", "translation_translationOptions": "Fordítási lehetőségek",
"translation_systemLanguage": "Rendszer nyelvé", "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_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"
} }
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingPinTitle": "PIN per l'accoppiamento Bluetooth", "scanner_linuxPairingPinTitle": "PIN per l'accoppiamento Bluetooth",
"scanner_linuxPairingHidePin": "Nascondi il PIN", "scanner_linuxPairingHidePin": "Nascondi il PIN",
"repeater_cliQuickClockSync": "Sincronizzazione dell'orologio", "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"
} }
+1 -13
View File
@@ -2099,17 +2099,5 @@
"scanner_linuxPairingPinTitle": "Bluetooth ペアリング PIN", "scanner_linuxPairingPinTitle": "Bluetooth ペアリング PIN",
"scanner_linuxPairingPinPrompt": "{deviceName}のPINを入力してください(なしの場合は空欄のまま)。", "scanner_linuxPairingPinPrompt": "{deviceName}のPINを入力してください(なしの場合は空欄のまま)。",
"repeater_cliQuickClockSync": "クロック同期", "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": "ゲスト向けツール"
} }
+1 -13
View File
@@ -2099,17 +2099,5 @@
"translation_translationOptions": "번역 옵션", "translation_translationOptions": "번역 옵션",
"translation_systemLanguage": "시스템 언어", "translation_systemLanguage": "시스템 언어",
"repeater_cliQuickClockSync": "시계 동기화", "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": "서버 정보"
} }
+2 -32
View File
@@ -3438,13 +3438,13 @@ abstract class AppLocalizations {
/// No description provided for @login_repeaterDescription. /// No description provided for @login_repeaterDescription.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Enter the repeater password for guest or admin access.'** /// **'Enter the repeater password to access settings and status.'**
String get login_repeaterDescription; String get login_repeaterDescription;
/// No description provided for @login_roomDescription. /// No description provided for @login_roomDescription.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Enter the room password for guest or admin access.'** /// **'Enter the room password to access settings and status.'**
String get login_roomDescription; String get login_roomDescription;
/// No description provided for @login_routing. /// No description provided for @login_routing.
@@ -3609,30 +3609,12 @@ abstract class AppLocalizations {
/// **'Room Server Management'** /// **'Room Server Management'**
String get room_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. /// No description provided for @repeater_managementTools.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Management Tools'** /// **'Management Tools'**
String get repeater_managementTools; 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. /// No description provided for @repeater_status.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3693,18 +3675,6 @@ abstract class AppLocalizations {
/// **'Configure repeater parameters'** /// **'Configure repeater parameters'**
String get repeater_settingsSubtitle; 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. /// No description provided for @repeater_statusTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+1 -18
View File
@@ -1240,7 +1240,7 @@ class AppLocalizationsBg extends AppLocalizations {
String get chat_noMessages => 'Няма съобщения.'; String get chat_noMessages => 'Няма съобщения.';
@override @override
String get chat_sendMessage => 'Изпратете съобщение'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2019,18 +2019,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get room_management => 'Управление на сървъра за стая'; String get room_management => 'Управление на сървъра за стая';
@override
String get repeater_guest => 'Информация за ретранслаторите';
@override
String get room_guest => 'Информация за сървъра на стаята';
@override @override
String get repeater_managementTools => 'Инструменти за управление'; String get repeater_managementTools => 'Инструменти за управление';
@override
String get repeater_guestTools => 'Инструменти за гости';
@override @override
String get repeater_status => 'Статус'; String get repeater_status => 'Статус';
@@ -2065,14 +2056,6 @@ class AppLocalizationsBg extends AppLocalizations {
String get repeater_settingsSubtitle => String get repeater_settingsSubtitle =>
'Конфигурирайте параметрите на репитера'; 'Конфигурирайте параметрите на репитера';
@override
String get repeater_clockSyncAfterLogin =>
'Синхронизиране на часовника след влизане';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Автоматично изпращайте съобщение \"синхронизиране на часовника\" след успешно влизане.';
@override @override
String get repeater_statusTitle => 'Статус на повтарянето'; String get repeater_statusTitle => 'Статус на повтарянето';
+1 -18
View File
@@ -1239,7 +1239,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get chat_noMessages => 'Noch keine Nachrichten.'; String get chat_noMessages => 'Noch keine Nachrichten.';
@override @override
String get chat_sendMessage => 'Nachricht senden'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2017,18 +2017,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get room_management => 'Raum-Server-Verwaltung'; 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 @override
String get repeater_managementTools => 'Verwaltungs-Tools'; String get repeater_managementTools => 'Verwaltungs-Tools';
@override
String get repeater_guestTools => 'Gastwerkzeuge';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2061,14 +2052,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Repeater-parameter konfigurieren'; 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 @override
String get repeater_statusTitle => 'Repeaterstatus'; String get repeater_statusTitle => 'Repeaterstatus';
+2 -18
View File
@@ -1871,11 +1871,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get login_repeaterDescription => String get login_repeaterDescription =>
'Enter the repeater password for guest or admin access.'; 'Enter the repeater password to access settings and status.';
@override @override
String get login_roomDescription => String get login_roomDescription =>
'Enter the room password for guest or admin access.'; 'Enter the room password to access settings and status.';
@override @override
String get login_routing => 'Routing'; String get login_routing => 'Routing';
@@ -1979,18 +1979,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get room_management => 'Room Server Management'; String get room_management => 'Room Server Management';
@override
String get repeater_guest => 'Repeater Information';
@override
String get room_guest => 'Room Server Information';
@override @override
String get repeater_managementTools => 'Management Tools'; String get repeater_managementTools => 'Management Tools';
@override
String get repeater_guestTools => 'Guest Tools';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2023,13 +2014,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Configure repeater parameters'; 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 @override
String get repeater_statusTitle => 'Repeater Status'; String get repeater_statusTitle => 'Repeater Status';
+1 -18
View File
@@ -1239,7 +1239,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get chat_noMessages => 'Aún no hay mensajes'; String get chat_noMessages => 'Aún no hay mensajes';
@override @override
String get chat_sendMessage => 'Enviar mensaje'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2015,18 +2015,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get room_management => 'Administración del Servidor de Habitación'; 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 @override
String get repeater_managementTools => 'Herramientas de Gestión'; String get repeater_managementTools => 'Herramientas de Gestión';
@override
String get repeater_guestTools => 'Herramientas para invitados';
@override @override
String get repeater_status => 'Estado'; String get repeater_status => 'Estado';
@@ -2059,14 +2050,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Configurar parámetros del repetidor'; 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 @override
String get repeater_statusTitle => 'Estado del Repetidor'; String get repeater_statusTitle => 'Estado del Repetidor';
+1 -18
View File
@@ -1244,7 +1244,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get chat_noMessages => 'Aucun message pour le moment.'; String get chat_noMessages => 'Aucun message pour le moment.';
@override @override
String get chat_sendMessage => 'Envoyer un message'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2026,18 +2026,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get room_management => 'Administrattion Room Server'; 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 @override
String get repeater_managementTools => 'Outils de Gestion'; String get repeater_managementTools => 'Outils de Gestion';
@override
String get repeater_guestTools => 'Outils pour les invités';
@override @override
String get repeater_status => 'État'; String get repeater_status => 'État';
@@ -2071,14 +2062,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get repeater_settingsSubtitle => String get repeater_settingsSubtitle =>
'Configurer les paramètres du répéteur'; '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 @override
String get repeater_statusTitle => 'État du répéteur'; String get repeater_statusTitle => 'État du répéteur';
+1 -18
View File
@@ -1247,7 +1247,7 @@ class AppLocalizationsHu extends AppLocalizations {
String get chat_noMessages => 'Még nincs üzenet.'; String get chat_noMessages => 'Még nincs üzenet.';
@override @override
String get chat_sendMessage => 'Üzenet küldése'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2030,18 +2030,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get room_management => 'Szoba-szerver kezelés'; 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 @override
String get repeater_managementTools => 'Menedzsmentes eszközök'; String get repeater_managementTools => 'Menedzsmentes eszközök';
@override
String get repeater_guestTools => 'Vendégek számára elérhető eszközök';
@override @override
String get repeater_status => 'Állapot'; String get repeater_status => 'Állapot';
@@ -2075,14 +2066,6 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Állítsa be a repeater paramétereket'; 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 @override
String get repeater_statusTitle => 'Adatkapcsolódás állapot'; String get repeater_statusTitle => 'Adatkapcsolódás állapot';
+1 -18
View File
@@ -1240,7 +1240,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get chat_noMessages => 'Nessun messaggio ancora'; String get chat_noMessages => 'Nessun messaggio ancora';
@override @override
String get chat_sendMessage => 'Invia messaggio'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2016,18 +2016,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get room_management => 'Gestione del Server di Camera'; 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 @override
String get repeater_managementTools => 'Strumenti di Gestione'; String get repeater_managementTools => 'Strumenti di Gestione';
@override
String get repeater_guestTools => 'Strumenti per gli ospiti';
@override @override
String get repeater_status => 'Stato'; String get repeater_status => 'Stato';
@@ -2062,14 +2053,6 @@ class AppLocalizationsIt extends AppLocalizations {
String get repeater_settingsSubtitle => String get repeater_settingsSubtitle =>
'Configura i parametri del ripetitore'; '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 @override
String get repeater_statusTitle => 'Stato del Ripetitore'; String get repeater_statusTitle => 'Stato del Ripetitore';
+1 -17
View File
@@ -1180,7 +1180,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get chat_noMessages => 'まだメッセージは届いていません'; String get chat_noMessages => 'まだメッセージは届いていません';
@override @override
String get chat_sendMessage => 'メッセージを送信する'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -1932,18 +1932,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get room_management => 'ルームサーバーの管理'; String get room_management => 'ルームサーバーの管理';
@override
String get repeater_guest => '繰り返し送信に関する情報';
@override
String get room_guest => 'ルームサーバーに関する情報';
@override @override
String get repeater_managementTools => '管理ツール'; String get repeater_managementTools => '管理ツール';
@override
String get repeater_guestTools => 'ゲスト向けツール';
@override @override
String get repeater_status => 'ステータス'; String get repeater_status => 'ステータス';
@@ -1974,13 +1965,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'リピーターのパラメータを設定する'; String get repeater_settingsSubtitle => 'リピーターのパラメータを設定する';
@override
String get repeater_clockSyncAfterLogin => 'ログイン後、時計の時刻を同期する';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'ログインが成功した場合、自動的に「時刻同期」を送信する。';
@override @override
String get repeater_statusTitle => '再送ステータス'; String get repeater_statusTitle => '再送ステータス';
+1 -17
View File
@@ -1175,7 +1175,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get chat_noMessages => '아직 메시지가 없습니다.'; String get chat_noMessages => '아직 메시지가 없습니다.';
@override @override
String get chat_sendMessage => '메시지를 보내기'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -1929,18 +1929,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get room_management => '방 서버 관리'; String get room_management => '방 서버 관리';
@override
String get repeater_guest => '반복 장비 정보';
@override
String get room_guest => '서버 정보';
@override @override
String get repeater_managementTools => '관리 도구'; String get repeater_managementTools => '관리 도구';
@override
String get repeater_guestTools => '손님용 도구';
@override @override
String get repeater_status => '상태'; String get repeater_status => '상태';
@@ -1971,13 +1962,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => '리피터 파라미터 설정'; String get repeater_settingsSubtitle => '리피터 파라미터 설정';
@override
String get repeater_clockSyncAfterLogin => '로그인 후 시계 동기화';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'성공적인 로그인 후, 자동으로 \"시간 동기화\"를 전송합니다.';
@override @override
String get repeater_statusTitle => '반복 장치 상태'; String get repeater_statusTitle => '반복 장치 상태';
+1 -18
View File
@@ -1228,7 +1228,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get chat_noMessages => 'Nog geen berichten.'; String get chat_noMessages => 'Nog geen berichten.';
@override @override
String get chat_sendMessage => 'Verzend bericht'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2003,18 +2003,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get room_management => 'Beheer Server Kamer'; 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 @override
String get repeater_managementTools => 'Beheerfuncties'; String get repeater_managementTools => 'Beheerfuncties';
@override
String get repeater_guestTools => 'Gastenfuncties';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2047,14 +2038,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Configureer repeaterparameters'; 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 @override
String get repeater_statusTitle => 'Status repeater'; String get repeater_statusTitle => 'Status repeater';
+1 -18
View File
@@ -1248,7 +1248,7 @@ class AppLocalizationsPl extends AppLocalizations {
String get chat_noMessages => 'Brak jeszcze wiadomości'; String get chat_noMessages => 'Brak jeszcze wiadomości';
@override @override
String get chat_sendMessage => 'Wyślij wiadomość'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2031,18 +2031,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get room_management => 'Zarządzanie Serwerem Pokoju'; 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 @override
String get repeater_managementTools => 'Narzędzia Zarządzania'; String get repeater_managementTools => 'Narzędzia Zarządzania';
@override
String get repeater_guestTools => 'Narzędzia dla gości';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2075,14 +2066,6 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Skonfiguruj parametry przekaźnika'; 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 @override
String get repeater_statusTitle => 'Status przekaźnika'; String get repeater_statusTitle => 'Status przekaźnika';
+1 -18
View File
@@ -1239,7 +1239,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get chat_noMessages => 'Ainda não existem mensagens.'; String get chat_noMessages => 'Ainda não existem mensagens.';
@override @override
String get chat_sendMessage => 'Enviar mensagem'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2015,18 +2015,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get room_management => 'Gerenciamento de Servidor de Sala'; 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 @override
String get repeater_managementTools => 'Ferramentas de Gerenciamento'; String get repeater_managementTools => 'Ferramentas de Gerenciamento';
@override
String get repeater_guestTools => 'Ferramentas para hóspedes';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2059,14 +2050,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Configurar parâmetros do repetidor'; 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 @override
String get repeater_statusTitle => 'Status do Repetidor'; String get repeater_statusTitle => 'Status do Repetidor';
+1 -18
View File
@@ -1239,7 +1239,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get chat_noMessages => 'Сообщений пока нет'; String get chat_noMessages => 'Сообщений пока нет';
@override @override
String get chat_sendMessage => 'Отправить сообщение'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2019,18 +2019,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get room_management => 'Управление сервером комнат'; String get room_management => 'Управление сервером комнат';
@override
String get repeater_guest => 'Информация о ретрансляторе';
@override
String get room_guest => 'Информация о сервере';
@override @override
String get repeater_managementTools => 'Инструменты управления'; String get repeater_managementTools => 'Инструменты управления';
@override
String get repeater_guestTools => 'Инструменты для гостей';
@override @override
String get repeater_status => 'Статус'; String get repeater_status => 'Статус';
@@ -2063,14 +2054,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Настройка параметров репитера'; String get repeater_settingsSubtitle => 'Настройка параметров репитера';
@override
String get repeater_clockSyncAfterLogin =>
'Синхронизация часов после входа в систему';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Автоматически отправлять сообщение \"синхронизация времени\" после успешной авторизации.';
@override @override
String get repeater_statusTitle => 'Статус репитера'; String get repeater_statusTitle => 'Статус репитера';
+1 -18
View File
@@ -1227,7 +1227,7 @@ class AppLocalizationsSk extends AppLocalizations {
String get chat_noMessages => 'Zatiaľ žiadne správy.'; String get chat_noMessages => 'Zatiaľ žiadne správy.';
@override @override
String get chat_sendMessage => 'Odoslať správu'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2004,18 +2004,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get room_management => 'Správa servera miestnosti'; 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 @override
String get repeater_managementTools => 'Nástroje na správu'; String get repeater_managementTools => 'Nástroje na správu';
@override
String get repeater_guestTools => 'Nástroje pre hostí';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2048,14 +2039,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Konfigurujte parametre opakovača'; 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 @override
String get repeater_statusTitle => 'Status opakého zboru'; String get repeater_statusTitle => 'Status opakého zboru';
+1 -17
View File
@@ -1225,7 +1225,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get chat_noMessages => 'Še ni sporočil.'; String get chat_noMessages => 'Še ni sporočil.';
@override @override
String get chat_sendMessage => 'Pošlji sporočilo'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2001,18 +2001,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get room_management => 'Upravljanje stremlišča'; 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 @override
String get repeater_managementTools => 'Upravne orodje'; String get repeater_managementTools => 'Upravne orodje';
@override
String get repeater_guestTools => 'Naložila za goste';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2047,13 +2038,6 @@ class AppLocalizationsSl extends AppLocalizations {
String get repeater_settingsSubtitle => String get repeater_settingsSubtitle =>
'Konfigurirajte parametre ponovitelja'; '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 @override
String get repeater_statusTitle => 'Status ponovitelja'; String get repeater_statusTitle => 'Status ponovitelja';
+1 -18
View File
@@ -1218,7 +1218,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get chat_noMessages => 'Inga meddelanden ännu'; String get chat_noMessages => 'Inga meddelanden ännu';
@override @override
String get chat_sendMessage => 'Skicka meddelande'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -1990,18 +1990,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get room_management => 'Rumserverhantering'; String get room_management => 'Rumserverhantering';
@override
String get repeater_guest => 'Information om repetorer';
@override
String get room_guest => 'Information om servern';
@override @override
String get repeater_managementTools => 'Administrationsverktyg'; String get repeater_managementTools => 'Administrationsverktyg';
@override
String get repeater_guestTools => 'Gästverktyg';
@override @override
String get repeater_status => 'Status'; String get repeater_status => 'Status';
@@ -2034,14 +2025,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Konfigurera återspolarparametrar'; 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 @override
String get repeater_statusTitle => 'Återspelsstatus'; String get repeater_statusTitle => 'Återspelsstatus';
+1 -17
View File
@@ -1231,7 +1231,7 @@ class AppLocalizationsUk extends AppLocalizations {
String get chat_noMessages => 'Поки немає повідомлень.'; String get chat_noMessages => 'Поки немає повідомлень.';
@override @override
String get chat_sendMessage => 'Надіслати повідомлення'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -2014,18 +2014,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get room_management => 'Адміністрування сервера кімнати'; String get room_management => 'Адміністрування сервера кімнати';
@override
String get repeater_guest => 'Інформація про ретранслятор';
@override
String get room_guest => 'Інформація про сервер кімнати';
@override @override
String get repeater_managementTools => 'Інструменти керування'; String get repeater_managementTools => 'Інструменти керування';
@override
String get repeater_guestTools => 'Інструменти для гостей';
@override @override
String get repeater_status => 'Статус'; String get repeater_status => 'Статус';
@@ -2059,13 +2050,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => 'Налаштувати параметри ретранслятора'; String get repeater_settingsSubtitle => 'Налаштувати параметри ретранслятора';
@override
String get repeater_clockSyncAfterLogin => 'Синхронізація годин після входу';
@override
String get repeater_clockSyncAfterLoginSubtitle =>
'Автоматично надсилати повідомлення \"синхронізація годин\" після успішного входу.';
@override @override
String get repeater_statusTitle => 'Статус ретранслятора'; String get repeater_statusTitle => 'Статус ретранслятора';
+1 -16
View File
@@ -1162,7 +1162,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get chat_noMessages => '暂无消息'; String get chat_noMessages => '暂无消息';
@override @override
String get chat_sendMessage => '发送消息'; String get chat_sendMessage => 'Send message';
@override @override
String chat_sendMessageTo(String contactName) { String chat_sendMessageTo(String contactName) {
@@ -1890,18 +1890,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get room_management => '房间服务器管理'; String get room_management => '房间服务器管理';
@override
String get repeater_guest => '重复器信息';
@override
String get room_guest => '服务器信息';
@override @override
String get repeater_managementTools => '管理工具'; String get repeater_managementTools => '管理工具';
@override
String get repeater_guestTools => '访客工具';
@override @override
String get repeater_status => '状态'; String get repeater_status => '状态';
@@ -1932,12 +1923,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => '配置转发节点参数'; String get repeater_settingsSubtitle => '配置转发节点参数';
@override
String get repeater_clockSyncAfterLogin => '登录后,自动同步时钟';
@override
String get repeater_clockSyncAfterLoginSubtitle => '在成功登录后,自动发送“时钟同步”指令。';
@override @override
String get repeater_statusTitle => '转发节点状态'; String get repeater_statusTitle => '转发节点状态';
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).", "scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).",
"scanner_linuxPairingPinTitle": "BluetoothkoppelingsPIN", "scanner_linuxPairingPinTitle": "BluetoothkoppelingsPIN",
"repeater_cliQuickDiscovery": "Ontdek Buren", "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"
} }
+1 -13
View File
@@ -2099,17 +2099,5 @@
"scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pozostaw puste, jeśli brak).", "scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pozostaw puste, jeśli brak).",
"scanner_linuxPairingPinTitle": "Kod PIN parowania Bluetooth", "scanner_linuxPairingPinTitle": "Kod PIN parowania Bluetooth",
"repeater_cliQuickClockSync": "Synchronizacja zegara", "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"
} }
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).", "scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).",
"scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth", "scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth",
"repeater_cliQuickClockSync": "Sincronização do Relógio", "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"
} }
+1 -13
View File
@@ -1301,17 +1301,5 @@
"scanner_linuxPairingHidePin": "Скрыть PIN", "scanner_linuxPairingHidePin": "Скрыть PIN",
"scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth", "scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth",
"repeater_cliQuickDiscovery": "Обнаружить Соседей", "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": "Инструменты для гостей"
} }
+1 -13
View File
@@ -2061,17 +2061,5 @@
"translation_translationOptions": "Možnosti prekladania", "translation_translationOptions": "Možnosti prekladania",
"translation_systemLanguage": "Jazyk systému", "translation_systemLanguage": "Jazyk systému",
"repeater_cliQuickClockSync": "Synchronizácia hodin", "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í"
} }
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).", "scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).",
"scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje", "scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje",
"repeater_cliQuickDiscovery": "Odkrijte sosede", "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"
} }
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).", "scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
"scanner_linuxPairingHidePin": "Dölj PIN", "scanner_linuxPairingHidePin": "Dölj PIN",
"repeater_cliQuickDiscovery": "Upptäck grannar", "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"
} }
+1 -13
View File
@@ -2061,17 +2061,5 @@
"scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).", "scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).",
"scanner_linuxPairingHidePin": "Приховати PIN", "scanner_linuxPairingHidePin": "Приховати PIN",
"repeater_cliQuickClockSync": "Синхронізація годинника", "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": "Надіслати повідомлення"
} }
+1 -13
View File
@@ -2066,17 +2066,5 @@
"translation_translationOptions": "翻译选项", "translation_translationOptions": "翻译选项",
"translation_systemLanguage": "系统语言", "translation_systemLanguage": "系统语言",
"repeater_cliQuickDiscovery": "发现邻居", "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": "服务器信息"
} }
+89 -14
View File
@@ -1,10 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'package:provider/provider.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/chrome_required_screen.dart';
import 'screens/discovery_screen.dart';
import 'utils/platform_info.dart'; import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart'; import 'connector/meshcore_connector.dart';
@@ -125,7 +131,7 @@ https://creativecommons.org/licenses/by/4.0/
}); });
} }
class MeshCoreApp extends StatelessWidget { class MeshCoreApp extends StatefulWidget {
final MeshCoreConnector connector; final MeshCoreConnector connector;
final MessageRetryService retryService; final MessageRetryService retryService;
final PathHistoryService pathHistoryService; final PathHistoryService pathHistoryService;
@@ -155,26 +161,94 @@ class MeshCoreApp extends StatelessWidget {
required this.timeoutPredictionService, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: connector), ChangeNotifierProvider.value(value: widget.connector),
ChangeNotifierProvider.value(value: retryService), ChangeNotifierProvider.value(value: widget.retryService),
ChangeNotifierProvider.value(value: pathHistoryService), ChangeNotifierProvider.value(value: widget.pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: widget.appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: widget.bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: widget.appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService), ChangeNotifierProvider.value(value: widget.chatTextScaleService),
ChangeNotifierProvider.value(value: translationService), ChangeNotifierProvider.value(value: widget.translationService),
ChangeNotifierProvider.value(value: uiViewStateService), ChangeNotifierProvider.value(value: widget.uiViewStateService),
Provider.value(value: storage), Provider.value(value: widget.storage),
Provider.value(value: mapTileCacheService), Provider.value(value: widget.mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService), ChangeNotifierProvider.value(value: widget.timeoutPredictionService),
], ],
child: Consumer<AppSettingsService>( child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) { builder: (context, settingsService, child) {
return MaterialApp( return WithForegroundTask(
child: MaterialApp(
navigatorKey: _navigatorKey,
title: 'MeshCore Open', title: 'MeshCore Open',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
localizationsDelegates: const [ localizationsDelegates: const [
@@ -216,6 +290,7 @@ class MeshCoreApp extends StatelessWidget {
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen() ? const ChromeRequiredScreen()
: const ScannerScreen(), : const ScannerScreen(),
),
); );
}, },
), ),
+2 -4
View File
@@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart'; import '../services/app_debug_log_service.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
class AppDebugLogScreen extends StatelessWidget { class AppDebugLogScreen extends StatelessWidget {
const AppDebugLogScreen({super.key}); const AppDebugLogScreen({super.key});
@@ -35,9 +34,8 @@ class AppDebugLogScreen extends StatelessWidget {
.join('\n'); .join('\n');
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.debugLog_copied)),
content: Text(context.l10n.debugLog_copied),
); );
} }
: null, : null,
+43 -41
View File
@@ -10,7 +10,6 @@ import '../services/app_settings_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'map_cache_screen.dart'; import 'map_cache_screen.dart';
class AppSettingsScreen extends StatelessWidget { class AppSettingsScreen extends StatelessWidget {
@@ -152,12 +151,13 @@ class AppSettingsScreen extends StatelessWidget {
.requestPermissions(); .requestPermissions();
if (!granted) { if (!granted) {
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.appSettings_notificationPermissionDenied, context.l10n.appSettings_notificationPermissionDenied,
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
} }
return; return;
@@ -166,14 +166,15 @@ class AppSettingsScreen extends StatelessWidget {
await settingsService.setNotificationsEnabled(value); await settingsService.setNotificationsEnabled(value);
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
value value
? context.l10n.appSettings_notificationsEnabled ? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled, : context.l10n.appSettings_notificationsDisabled,
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
} }
}, },
@@ -300,14 +301,15 @@ class AppSettingsScreen extends StatelessWidget {
value: settingsService.settings.clearPathOnMaxRetry, value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) { onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value); settingsService.setClearPathOnMaxRetry(value);
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
value value
? context.l10n.appSettings_pathsWillBeCleared ? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared, : context.l10n.appSettings_pathsWillNotBeCleared,
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
}, },
), ),
@@ -327,14 +329,15 @@ class AppSettingsScreen extends StatelessWidget {
value: settingsService.settings.autoRouteRotationEnabled, value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) { onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value); settingsService.setAutoRouteRotationEnabled(value);
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
value value
? context.l10n.appSettings_autoRouteRotationEnabled ? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled, : context.l10n.appSettings_autoRouteRotationDisabled,
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
}, },
), ),
@@ -1062,25 +1065,25 @@ class AppSettingsScreen extends StatelessWidget {
children: [ children: [
Text(context.l10n.appSettings_showNodesDiscoveredWithin), Text(context.l10n.appSettings_showNodesDiscoveredWithin),
const SizedBox(height: 16), const SizedBox(height: 16),
RadioListTile<double>( ListTile(
title: Text(context.l10n.appSettings_allTime), title: Text(context.l10n.appSettings_allTime),
value: 0, leading: Radio<double>(value: 0),
), ),
RadioListTile<double>( ListTile(
title: Text(context.l10n.appSettings_lastHour), title: Text(context.l10n.appSettings_lastHour),
value: 1, leading: Radio<double>(value: 1),
), ),
RadioListTile<double>( ListTile(
title: Text(context.l10n.appSettings_last6Hours), title: Text(context.l10n.appSettings_last6Hours),
value: 6, leading: Radio<double>(value: 6),
), ),
RadioListTile<double>( ListTile(
title: Text(context.l10n.appSettings_last24Hours), title: Text(context.l10n.appSettings_last24Hours),
value: 24, leading: Radio<double>(value: 24),
), ),
RadioListTile<double>( ListTile(
title: Text(context.l10n.appSettings_lastWeek), title: Text(context.l10n.appSettings_lastWeek),
value: 168, leading: Radio<double>(value: 168),
), ),
], ],
), ),
@@ -1114,13 +1117,13 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
RadioListTile<UnitSystem>( ListTile(
title: Text(context.l10n.appSettings_unitsMetric), title: Text(context.l10n.appSettings_unitsMetric),
value: UnitSystem.metric, leading: const Radio<UnitSystem>(value: UnitSystem.metric),
), ),
RadioListTile<UnitSystem>( ListTile(
title: Text(context.l10n.appSettings_unitsImperial), title: Text(context.l10n.appSettings_unitsImperial),
value: UnitSystem.imperial, leading: const Radio<UnitSystem>(value: UnitSystem.imperial),
), ),
], ],
), ),
@@ -1161,9 +1164,8 @@ class AppSettingsScreen extends StatelessWidget {
String? id, String? id,
}) async { }) async {
if (sourceUrl.isEmpty) { if (sourceUrl.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.translation_enterUrlFirst)),
content: Text(context.l10n.translation_enterUrlFirst),
); );
return; return;
} }
@@ -1174,24 +1176,23 @@ class AppSettingsScreen extends StatelessWidget {
id: id, id: id,
); );
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.translation_modelDownloaded)),
content: Text(context.l10n.translation_modelDownloaded),
); );
await settingsService.setTranslationEnabled(true); await settingsService.setTranslationEnabled(true);
} on TranslationDownloadCancelled { } on TranslationDownloadCancelled {
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.translation_downloadStopped)),
content: Text(context.l10n.translation_downloadStopped),
); );
} catch (error) { } catch (error) {
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.translation_downloadFailed(error.toString()), context.l10n.translation_downloadFailed(error.toString()),
), ),
),
); );
} }
} }
@@ -1235,16 +1236,16 @@ class AppSettingsScreen extends StatelessWidget {
try { try {
await translationService.removeModel(model); await translationService.removeModel(model);
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
// TODO: l10n // TODO: l10n
content: Text('Deleted ${translationModelFriendlyName(model)}.'), content: Text('Deleted ${translationModelFriendlyName(model)}.'),
),
); );
} catch (error) { } catch (error) {
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Delete failed: $error')),
content: Text('Delete failed: $error'),
); // TODO: l10n ); // TODO: l10n
} }
} }
@@ -1278,14 +1279,15 @@ class AppSettingsScreen extends StatelessWidget {
onChanged: (value) async { onChanged: (value) async {
await settingsService.setAppDebugLogEnabled(value); await settingsService.setAppDebugLogEnabled(value);
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
value value
? context.l10n.appSettings_appDebugLoggingEnabled ? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled, : context.l10n.appSettings_appDebugLoggingDisabled,
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
}, },
), ),
+3 -3
View File
@@ -5,7 +5,6 @@ import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart'; import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
enum _BleLogView { frames, rawLogRx } enum _BleLogView { frames, rawLogRx }
@@ -53,9 +52,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
.join('\n'); .join('\n');
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.debugLog_bleCopied), content: Text(context.l10n.debugLog_bleCopied),
),
); );
} }
: null, : null,
+11 -15
View File
@@ -14,7 +14,6 @@ import '../connector/meshcore_protocol.dart';
import '../helpers/gif_helper.dart'; import '../helpers/gif_helper.dart';
import '../helpers/reaction_helper.dart'; import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart'; import '../helpers/utf8_length_limiter.dart';
import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
@@ -145,10 +144,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Future<void> _scrollToMessage(String messageId) async { Future<void> _scrollToMessage(String messageId) async {
final key = _messageKeys[messageId]; final key = _messageKeys[messageId];
if (key == null) { if (key == null) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.chat_originalMessageNotFound), content: Text(context.l10n.chat_originalMessageNotFound),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
return; return;
} }
@@ -1151,10 +1151,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final now = DateTime.now(); final now = DateTime.now();
if (_lastChannelSendAt != null && if (_lastChannelSendAt != null &&
now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) { now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.chat_sendCooldown), ).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
);
return; return;
} }
_lastChannelSendAt = now; _lastChannelSendAt = now;
@@ -1196,9 +1195,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final maxBytes = maxChannelMessageBytes(connector.selfName); final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) { if (utf8.encode(messageText).length > maxBytes) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
content: Text(context.l10n.chat_messageTooLong(maxBytes)),
); );
return; return;
} }
@@ -1325,19 +1323,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.chat_messageCopied), ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
);
} }
Future<void> _deleteMessage(ChannelMessage message) async { Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message); await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.chat_messageDeleted), ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
);
} }
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
+72 -41
View File
@@ -24,7 +24,6 @@ import '../widgets/empty_state.dart';
import '../widgets/qr_code_display.dart'; import '../widgets/qr_code_display.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/unread_badge.dart'; import '../widgets/unread_badge.dart';
import '../helpers/snack_bar_builder.dart';
import 'channel_chat_screen.dart'; import 'channel_chat_screen.dart';
import 'community_qr_scanner_screen.dart'; import 'community_qr_scanner_screen.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
@@ -810,13 +809,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
onPressed: () async { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, dialogContext,
).showSnackBar(
SnackBar(
content: Text( content: Text(
dialogContext dialogContext
.l10n .l10n
.channels_enterChannelName, .channels_enterChannelName,
), ),
),
); );
return; return;
} }
@@ -835,10 +837,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
nextIndex, nextIndex,
); );
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.channels_channelAdded(name), context.l10n.channels_channelAdded(
name,
),
),
), ),
); );
} }
@@ -892,13 +897,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final name = nameController.text.trim(); final name = nameController.text.trim();
final pskHex = pskController.text.trim(); final pskHex = pskController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, dialogContext,
).showSnackBar(
SnackBar(
content: Text( content: Text(
dialogContext dialogContext
.l10n .l10n
.channels_enterChannelName, .channels_enterChannelName,
), ),
),
); );
return; return;
} }
@@ -906,23 +914,29 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try { try {
psk = Channel.parsePskHex(pskHex); psk = Channel.parsePskHex(pskHex);
} on FormatException { } on FormatException {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, dialogContext,
).showSnackBar(
SnackBar(
content: Text( content: Text(
dialogContext dialogContext
.l10n .l10n
.channels_pskMustBe32Hex, .channels_pskMustBe32Hex,
), ),
),
); );
return; return;
} }
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk); connector.setChannel(nextIndex, name, psk);
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.channels_channelAdded(name), context.l10n.channels_channelAdded(
name,
),
),
), ),
); );
} }
@@ -953,11 +967,12 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
connector.setChannel(nextIndex, 'Public', psk); connector.setChannel(nextIndex, 'Public', psk);
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.channels_publicChannelAdded, context.l10n.channels_publicChannelAdded,
), ),
),
); );
} }
}, },
@@ -1082,13 +1097,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
onPressed: () async { onPressed: () async {
var hashtag = hashtagController.text.trim(); var hashtag = hashtagController.text.trim();
if (hashtag.isEmpty) { if (hashtag.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, dialogContext,
).showSnackBar(
SnackBar(
content: Text( content: Text(
dialogContext dialogContext
.l10n .l10n
.channels_enterChannelName, .channels_enterChannelName,
), ),
),
); );
return; return;
} }
@@ -1107,13 +1125,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} else { } else {
// Community hashtag - HMAC derivation from community secret // Community hashtag - HMAC derivation from community secret
if (selectedCommunity == null) { if (selectedCommunity == null) {
showDismissibleSnackBar( ScaffoldMessenger.of(
dialogContext, dialogContext,
).showSnackBar(
SnackBar(
content: Text( content: Text(
dialogContext dialogContext
.l10n .l10n
.community_selectCommunity, .community_selectCommunity,
), ),
),
); );
return; return;
} }
@@ -1138,13 +1159,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk, psk,
); );
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.channels_channelAdded( context.l10n.channels_channelAdded(
channelName, channelName,
), ),
), ),
),
); );
} }
}, },
@@ -1237,11 +1259,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
onPressed: () async { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, dialogContext,
).showSnackBar(
SnackBar(
content: Text( content: Text(
dialogContext.l10n.community_enterName, dialogContext.l10n.community_enterName,
), ),
),
); );
return; return;
} }
@@ -1276,11 +1301,12 @@ class _ChannelsScreenState extends State<ChannelsScreen>
_loadCommunities(); _loadCommunities();
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.community_created(name), context.l10n.community_created(name),
), ),
),
); );
// Show QR code dialog // Show QR code dialog
@@ -1468,9 +1494,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try { try {
psk = Channel.parsePskHex(pskHex); psk = Channel.parsePskHex(pskHex);
} on FormatException { } on FormatException {
showDismissibleSnackBar( ScaffoldMessenger.of(dialogContext).showSnackBar(
dialogContext, SnackBar(
content: Text(dialogContext.l10n.channels_pskMustBe32Hex), content: Text(dialogContext.l10n.channels_pskMustBe32Hex),
),
); );
return; return;
} }
@@ -1483,16 +1510,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
smazEnabled, smazEnabled,
); );
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.channels_channelUpdated(name)), content: Text(context.l10n.channels_channelUpdated(name)),
),
); );
} catch (e, st) { } catch (e, st) {
debugPrint(st.toString()); debugPrint(st.toString());
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Failed to update channel: $e')),
content: Text('Failed to update channel: $e'),
); );
} }
}, },
@@ -1532,20 +1559,22 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.channels_channelDeleted(channel.name), context.l10n.channels_channelDeleted(channel.name),
), ),
),
); );
} catch (e, st) { } catch (e, st) {
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.channels_channelDeleteFailed(channel.name), context.l10n.channels_channelDeleteFailed(channel.name),
), ),
),
); );
// Preserve existing logging (if it was there) // Preserve existing logging (if it was there)
@@ -1565,9 +1594,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) { void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
final psk = Channel.parsePskHex(Channel.publicChannelPsk); final psk = Channel.parsePskHex(Channel.publicChannelPsk);
connector.setChannel(0, 'Public', psk); connector.setChannel(0, 'Public', psk);
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.channels_publicChannelAdded)),
content: Text(context.l10n.channels_publicChannelAdded),
); );
} }
@@ -1782,9 +1810,12 @@ class _ChannelsScreenState extends State<ChannelsScreen>
_loadCommunities(); _loadCommunities();
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.community_deleted(community.name)), content: Text(
context.l10n.community_deleted(community.name),
),
),
); );
} }
}, },
+28 -27
View File
@@ -43,7 +43,6 @@ import '../widgets/radio_stats_entry.dart';
import '../widgets/translated_message_content.dart'; import '../widgets/translated_message_content.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart';
import 'telemetry_screen.dart'; import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
@@ -634,10 +633,9 @@ class _ChatScreenState extends State<ChatScreen> {
final now = DateTime.now(); final now = DateTime.now();
if (_lastTextSendAt != null && if (_lastTextSendAt != null &&
now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) { now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.chat_sendCooldown), ).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
);
return; return;
} }
_lastTextSendAt = now; _lastTextSendAt = now;
@@ -673,9 +671,8 @@ class _ChatScreenState extends State<ChatScreen> {
} }
final maxBytes = maxContactMessageBytes(); final maxBytes = maxContactMessageBytes();
if (utf8.encode(outgoingText).length > maxBytes) { if (utf8.encode(outgoingText).length > maxBytes) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
content: Text(context.l10n.chat_messageTooLong(maxBytes)),
); );
return; return;
} }
@@ -863,12 +860,15 @@ class _ChatScreenState extends State<ChatScreen> {
_showFullPathDialog(context, path.pathBytes), _showFullPathDialog(context, path.pathBytes),
onTap: () async { onTap: () async {
if (path.pathBytes.isEmpty) { if (path.pathBytes.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.chat_pathDetailsNotAvailable, context
.l10n
.chat_pathDetailsNotAvailable,
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
return; return;
} }
@@ -952,10 +952,11 @@ class _ChatScreenState extends State<ChatScreen> {
_resolveContact(connector), _resolveContact(connector),
); );
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.chat_pathCleared), content: Text(context.l10n.chat_pathCleared),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
Navigator.pop(context); Navigator.pop(context);
}, },
@@ -981,10 +982,11 @@ class _ChatScreenState extends State<ChatScreen> {
pathLen: -1, pathLen: -1,
); );
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.chat_floodModeEnabled), content: Text(context.l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
Navigator.pop(context); Navigator.pop(context);
}, },
@@ -1018,10 +1020,11 @@ class _ChatScreenState extends State<ChatScreen> {
void _showFullPathDialog(BuildContext context, List<int> pathBytes) { void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
if (pathBytes.isEmpty) { if (pathBytes.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable), content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
return; return;
} }
@@ -1134,10 +1137,11 @@ class _ChatScreenState extends State<ChatScreen> {
: (verified : (verified
? context.l10n.chat_pathDeviceConfirmed ? context.l10n.chat_pathDeviceConfirmed
: context.l10n.chat_pathDeviceNotConfirmed); : context.l10n.chat_pathDeviceNotConfirmed);
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.chat_pathSetHops(hopCount, status)), content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
),
); );
} }
@@ -1486,29 +1490,26 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.chat_messageCopied), ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
);
} }
Future<void> _deleteMessage(Message message) async { Future<void> _deleteMessage(Message message) async {
await context.read<MeshCoreConnector>().deleteMessage(message); await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.chat_messageDeleted), ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
);
} }
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage(_resolveContact(connector), message.text); connector.sendMessage(_resolveContact(connector), message.text);
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.chat_retryingMessage), ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
);
} }
void _showEmojiPicker(Message message, Contact senderContact) { void _showEmojiPicker(Message message, Contact senderContact) {
+9 -7
View File
@@ -8,7 +8,6 @@ import '../models/community.dart';
import '../storage/community_store.dart'; import '../storage/community_store.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/qr_scanner_widget.dart'; import '../widgets/qr_scanner_widget.dart';
import '../helpers/snack_bar_builder.dart';
/// Screen for scanning community QR codes to join communities. /// Screen for scanning community QR codes to join communities.
/// ///
@@ -77,10 +76,11 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.community_invalidQrCode), content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} finally { } finally {
@@ -93,11 +93,12 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
} }
void _showInvalidQrError(BuildContext context) { void _showInvalidQrError(BuildContext context) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.community_invalidQrCode), content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
} }
@@ -228,10 +229,11 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
} }
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.community_joined(community.name)), content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green, backgroundColor: Colors.green,
),
); );
// Return to previous screen // Return to previous screen
+48 -50
View File
@@ -27,7 +27,6 @@ import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart'; import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart'; import '../widgets/room_login_dialog.dart';
import '../widgets/unread_badge.dart'; import '../widgets/unread_badge.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'discovery_screen.dart'; import 'discovery_screen.dart';
@@ -151,10 +150,9 @@ class _ContactsScreenState extends State<ContactsScreen>
} }
void _showGroupsUnavailableMessage(BuildContext context) { void _showGroupsUnavailableMessage(BuildContext context) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(context.l10n.common_loading), ).showSnackBar(SnackBar(content: Text(context.l10n.common_loading)));
);
} }
void _setupFrameListener() { void _setupFrameListener() {
@@ -171,9 +169,10 @@ class _ContactsScreenState extends State<ContactsScreen>
// Validate packet has expected minimum size (98+ bytes per protocol) // Validate packet has expected minimum size (98+ bytes per protocol)
if (advertPacket.length < 98) { if (advertPacket.length < 98) {
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_invalidAdvertFormat), content: Text(context.l10n.contacts_invalidAdvertFormat),
),
); );
} }
_pendingOperations.remove(ContactOperationType.export); _pendingOperations.remove(ContactOperationType.export);
@@ -188,23 +187,24 @@ class _ContactsScreenState extends State<ContactsScreen>
if (!mounted) return; if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) { if (_pendingOperations.contains(ContactOperationType.import)) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.contacts_contactImported)),
content: Text(context.l10n.contacts_contactImported),
); );
} }
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertSent), content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
),
); );
} }
if (_pendingOperations.contains(ContactOperationType.export)) { if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopied), content: Text(context.l10n.contacts_contactAdvertCopied),
),
); );
} }
@@ -216,22 +216,25 @@ class _ContactsScreenState extends State<ContactsScreen>
if (!mounted) return; if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) { if (_pendingOperations.contains(ContactOperationType.import)) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_contactImportFailed), content: Text(context.l10n.contacts_contactImportFailed),
),
); );
} }
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
),
); );
} }
if (_pendingOperations.contains(ContactOperationType.export)) { if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopyFailed), content: Text(context.l10n.contacts_contactAdvertCopyFailed),
),
); );
} }
@@ -268,9 +271,8 @@ class _ContactsScreenState extends State<ContactsScreen>
final clipboardData = await Clipboard.getData('text/plain'); final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData == null || clipboardData.text == null) { if (clipboardData == null || clipboardData.text == null) {
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)),
content: Text(context.l10n.contacts_clipboardEmpty),
); );
} }
return; return;
@@ -278,9 +280,8 @@ class _ContactsScreenState extends State<ContactsScreen>
final text = clipboardData.text!.trim(); final text = clipboardData.text!.trim();
if (!text.startsWith('meshcore://')) { if (!text.startsWith('meshcore://')) {
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
content: Text(context.l10n.contacts_invalidAdvertFormat),
); );
} }
return; return;
@@ -293,9 +294,8 @@ class _ContactsScreenState extends State<ContactsScreen>
connector.importContact(importContactFrame); connector.importContact(importContactFrame);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
content: Text(context.l10n.contacts_invalidAdvertFormat),
); );
} }
} }
@@ -330,10 +330,11 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
onTap: () => { onTap: () => {
connector.sendSelfAdvert(flood: false), connector.sendSelfAdvert(flood: false),
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.settings_advertisementSent), content: Text(context.l10n.settings_advertisementSent),
), ),
),
}, },
), ),
PopupMenuItem( PopupMenuItem(
@@ -346,10 +347,11 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
onTap: () => { onTap: () => {
connector.sendSelfAdvert(flood: true), connector.sendSelfAdvert(flood: true),
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.settings_advertisementSent), content: Text(context.l10n.settings_advertisementSent),
), ),
),
}, },
), ),
PopupMenuItem( PopupMenuItem(
@@ -961,16 +963,13 @@ class _ContactsScreenState extends State<ContactsScreen>
context: context, context: context,
builder: (context) => RepeaterLoginDialog( builder: (context) => RepeaterLoginDialog(
repeater: repeater, repeater: repeater,
onLogin: (password, isAdmin) { onLogin: (password) {
// Navigate to repeater hub screen after successful login // Navigate to repeater hub screen after successful login
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => RepeaterHubScreen( builder: (context) =>
repeater: repeater, RepeaterHubScreen(repeater: repeater, password: password),
password: password,
isAdmin: isAdmin,
),
), ),
); );
}, },
@@ -987,18 +986,14 @@ class _ContactsScreenState extends State<ContactsScreen>
context: context, context: context,
builder: (context) => RoomLoginDialog( builder: (context) => RoomLoginDialog(
room: room, room: room,
onLogin: (password, isAdmin) { onLogin: (password) {
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex); context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) =>
destination == RoomLoginDestination.management destination == RoomLoginDestination.management
? RepeaterHubScreen( ? RepeaterHubScreen(repeater: room, password: password)
repeater: room,
password: password,
isAdmin: isAdmin,
)
: ChatScreen(contact: room), : ChatScreen(contact: room),
), ),
); );
@@ -1151,17 +1146,19 @@ class _ContactsScreenState extends State<ContactsScreen>
onPressed: () async { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_groupNameRequired), content: Text(context.l10n.contacts_groupNameRequired),
),
); );
return; return;
} }
if (name.toLowerCase() == if (name.toLowerCase() ==
contactsAllGroupsValue.toLowerCase()) { contactsAllGroupsValue.toLowerCase()) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.contacts_groupNameReserved), content: Text(context.l10n.contacts_groupNameReserved),
),
); );
return; return;
} }
@@ -1170,11 +1167,12 @@ class _ContactsScreenState extends State<ContactsScreen>
return g.name.toLowerCase() == name.toLowerCase(); return g.name.toLowerCase() == name.toLowerCase();
}); });
if (exists) { if (exists) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.contacts_groupAlreadyExists(name), context.l10n.contacts_groupAlreadyExists(name),
), ),
),
); );
return; return;
} }
+17 -4
View File
@@ -8,11 +8,11 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/notification_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type } enum DiscoverySortOption { lastSeen, name, type }
@@ -32,6 +32,20 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen; DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen;
Timer? _searchDebounce; 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -235,9 +249,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final hexString = pubKeyToHex(contact.rawPacket!); final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
content: Text(context.l10n.contacts_contactAdvertCopied),
); );
break; break;
case 'delete_contact': case 'delete_contact':
+9 -11
View File
@@ -8,7 +8,6 @@ import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
class MapCacheScreen extends StatefulWidget { class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key}); const MapCacheScreen({super.key});
@@ -113,17 +112,15 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Future<void> _startDownload() async { Future<void> _startDownload() async {
final bounds = _selectedBounds; final bounds = _selectedBounds;
if (bounds == null) { if (bounds == null) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.mapCache_selectAreaFirst)),
content: Text(context.l10n.mapCache_selectAreaFirst),
); );
return; return;
} }
if (_estimatedTiles == 0) { if (_estimatedTiles == 0) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.mapCache_noTilesToDownload)),
content: Text(context.l10n.mapCache_noTilesToDownload),
); );
return; return;
} }
@@ -185,7 +182,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
result.failed, result.failed,
) )
: context.l10n.mapCache_cachedTiles(result.downloaded); : context.l10n.mapCache_cachedTiles(result.downloaded);
showDismissibleSnackBar(context, content: Text(message)); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
Future<void> _clearCache() async { Future<void> _clearCache() async {
@@ -211,9 +210,8 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final cacheService = context.read<MapTileCacheService>(); final cacheService = context.read<MapTileCacheService>();
await cacheService.clearCache(); await cacheService.clearCache();
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.mapCache_offlineCacheCleared)),
content: Text(context.l10n.mapCache_offlineCacheCleared),
); );
} }
+9 -19
View File
@@ -29,7 +29,6 @@ import 'chat_screen.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import '../widgets/repeater_login_dialog.dart'; import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart'; import '../widgets/room_login_dialog.dart';
import '../helpers/snack_bar_builder.dart';
import 'repeater_hub_screen.dart'; import 'repeater_hub_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'line_of_sight_map_screen.dart'; import 'line_of_sight_map_screen.dart';
@@ -1367,16 +1366,13 @@ class _MapScreenState extends State<MapScreen> {
context: context, context: context,
builder: (context) => RepeaterLoginDialog( builder: (context) => RepeaterLoginDialog(
repeater: repeater, repeater: repeater,
onLogin: (password, isAdmin) { onLogin: (password) {
// Navigate to repeater hub screen after successful login // Navigate to repeater hub screen after successful login
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => RepeaterHubScreen( builder: (context) =>
repeater: repeater, RepeaterHubScreen(repeater: repeater, password: password),
password: password,
isAdmin: isAdmin,
),
), ),
); );
}, },
@@ -1389,8 +1385,7 @@ class _MapScreenState extends State<MapScreen> {
context: context, context: context,
builder: (context) => RoomLoginDialog( builder: (context) => RoomLoginDialog(
room: room, room: room,
// onLogin(password, isAdmin) isAdmin not used for room caht screen onLogin: (password) {
onLogin: (password, _) {
// Navigate to chat screen after successful login // Navigate to chat screen after successful login
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex); context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
Navigator.push( Navigator.push(
@@ -1664,10 +1659,7 @@ class _MapScreenState extends State<MapScreen> {
); );
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( messenger.showSnackBar(SnackBar(content: Text(successMsg)));
messenger.context,
content: Text(successMsg),
);
}, },
), ),
ListTile( ListTile(
@@ -1689,9 +1681,8 @@ class _MapScreenState extends State<MapScreen> {
required String flags, required String flags,
}) async { }) async {
if (!connector.isConnected) { if (!connector.isConnected) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.map_connectToShareMarkers)),
content: Text(context.l10n.map_connectToShareMarkers),
); );
return; return;
} }
@@ -2280,9 +2271,8 @@ class _MapScreenState extends State<MapScreen> {
_points.clear(); _points.clear();
_polylines.clear(); _polylines.clear();
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.map_pathTraceCancelled)),
content: Text(l10n.map_pathTraceCancelled),
); );
}, },
tooltip: l10n.common_cancel, tooltip: l10n.common_cancel,
+9 -7
View File
@@ -11,7 +11,6 @@ import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
import '../widgets/snr_indicator.dart'; import '../widgets/snr_indicator.dart';
import '../helpers/snack_bar_builder.dart';
class NeighborsScreen extends StatefulWidget { class NeighborsScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -164,10 +163,11 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
_neighborCount = neighborCount; _neighborCount = neighborCount;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.neighbors_receivedData), content: Text(context.l10n.neighbors_receivedData),
backgroundColor: Colors.green, backgroundColor: Colors.green,
),
); );
_statusTimeout?.cancel(); _statusTimeout?.cancel();
if (!mounted) return; if (!mounted) return;
@@ -224,10 +224,11 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
_isLoading = false; _isLoading = false;
_isLoaded = false; _isLoaded = false;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.neighbors_requestTimedOut), content: Text(context.l10n.neighbors_requestTimedOut),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
_recordStatusResult(false); _recordStatusResult(false);
}); });
@@ -238,10 +239,11 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
_isLoaded = false; _isLoaded = false;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.neighbors_errorLoading(e.toString())), content: Text(context.l10n.neighbors_errorLoading(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
+2 -4
View File
@@ -9,7 +9,6 @@ import '../connector/meshcore_protocol.dart';
import '../widgets/debug_frame_viewer.dart'; import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterCliScreen extends StatefulWidget { class RepeaterCliScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -337,9 +336,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
if (_commandController.text.trim().isNotEmpty) { if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true); _sendCommand(showDebug: true);
} else { } else {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.repeater_enterCommandFirst)),
content: Text(l10n.repeater_enterCommandFirst),
); );
} }
}, },
+3 -17
View File
@@ -13,13 +13,11 @@ import 'neighbors_screen.dart';
class RepeaterHubScreen extends StatelessWidget { class RepeaterHubScreen extends StatelessWidget {
final Contact repeater; final Contact repeater;
final String password; final String password;
final bool isAdmin;
const RepeaterHubScreen({ const RepeaterHubScreen({
super.key, super.key,
required this.repeater, required this.repeater,
required this.password, required this.password,
required this.isAdmin,
}); });
@override @override
@@ -35,18 +33,11 @@ class RepeaterHubScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isAdmin)
Text( Text(
repeater.type == advTypeRepeater repeater.type == advTypeRepeater
? l10n.repeater_management ? l10n.repeater_management
: l10n.room_management, : l10n.room_management,
), ),
if (!isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_guest
: l10n.room_guest,
),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle( style: const TextStyle(
@@ -122,7 +113,6 @@ class RepeaterHubScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (isAdmin)
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
@@ -180,9 +170,7 @@ class RepeaterHubScreen extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
isAdmin l10n.repeater_managementTools,
? l10n.repeater_managementTools
: l10n.repeater_guestTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -222,9 +210,8 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
if (isAdmin) const SizedBox(height: 12), const SizedBox(height: 12),
// CLI button // CLI button
if (isAdmin)
_buildManagementCard( _buildManagementCard(
context, context,
icon: Icons.terminal, icon: Icons.terminal,
@@ -261,9 +248,8 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
if (isAdmin) const SizedBox(height: 12), const SizedBox(height: 12),
// Settings button // Settings button
if (isAdmin)
_buildManagementCard( _buildManagementCard(
context, context,
icon: Icons.settings, icon: Icons.settings,
+19 -45
View File
@@ -8,9 +8,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/app_debug_log_service.dart'; import '../services/app_debug_log_service.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../services/storage_service.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterSettingsScreen extends StatefulWidget { class RepeaterSettingsScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -27,8 +25,6 @@ class RepeaterSettingsScreen extends StatefulWidget {
} }
class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> { class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
final StorageService _storage = StorageService();
bool _isLoading = false; bool _isLoading = false;
bool _hasChanges = false; bool _hasChanges = false;
bool _refreshingBasic = false; bool _refreshingBasic = false;
@@ -63,7 +59,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
bool _repeatEnabled = true; bool _repeatEnabled = true;
bool _allowReadOnly = true; bool _allowReadOnly = true;
bool _privacyMode = false; bool _privacyMode = false;
bool _autoClockSyncAfterLogin = false;
// Advertisement settings // Advertisement settings
bool _advertEnable = true; bool _advertEnable = true;
@@ -469,16 +464,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) { if (mounted) {
if (successCount > 0) { if (successCount > 0) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.repeater_refreshed(label)), content: Text(l10n.repeater_refreshed(label)),
backgroundColor: Colors.green, backgroundColor: Colors.green,
),
); );
} else { } else {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.repeater_errorRefreshing(label)), content: Text(l10n.repeater_errorRefreshing(label)),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
@@ -569,15 +566,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_lonController.text = widget.repeater.longitude?.toString() ?? ''; _lonController.text = widget.repeater.longitude?.toString() ?? '';
} }
}); });
final autoClockSync = await _storage
.getRepeaterAutoClockSyncAfterLoginEnabled(
widget.repeater.publicKeyHex,
);
if (!mounted) return;
setState(() {
_autoClockSyncAfterLogin = autoClockSync;
});
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
@@ -665,10 +653,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}); });
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.repeater_settingsSaved), content: Text(context.l10n.repeater_settingsSaved),
backgroundColor: Colors.green, backgroundColor: Colors.green,
),
); );
} }
} catch (e) { } catch (e) {
@@ -677,12 +666,13 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}); });
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
context.l10n.repeater_errorSavingSettings(e.toString()), context.l10n.repeater_errorSavingSettings(e.toString()),
), ),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
@@ -1149,21 +1139,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
onRefresh: _refreshAllowReadOnly, onRefresh: _refreshAllowReadOnly,
refreshTooltip: l10n.repeater_refreshGuestAccess, 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 // Privacy mode - hidden until fully implemented
// _buildFeatureToggleRow( // _buildFeatureToggleRow(
// title: l10n.repeater_privacyMode, // title: l10n.repeater_privacyMode,
@@ -1426,10 +1401,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (command == 'erase') { if (command == 'erase') {
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(l10n.repeater_eraseSerialOnly), ).showSnackBar(SnackBar(content: Text(l10n.repeater_eraseSerialOnly)));
);
} }
return; return;
} }
@@ -1451,17 +1425,17 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
await connector.sendFrame(frame); await connector.sendFrame(frame);
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.repeater_commandSent(command))),
content: Text(l10n.repeater_commandSent(command)),
); );
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.repeater_errorSendingCommand(e.toString())), content: Text(l10n.repeater_errorSendingCommand(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
+9 -6
View File
@@ -12,7 +12,6 @@ import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../utils/battery_utils.dart'; import '../utils/battery_utils.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterStatusScreen extends StatefulWidget { class RepeaterStatusScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -310,10 +309,11 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.repeater_statusRequestTimeout), content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
_recordStatusResult(false); _recordStatusResult(false);
}); });
@@ -323,10 +323,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_isLoading = false; _isLoading = false;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())), content: Text(
context.l10n.repeater_errorLoadingStatus(e.toString()),
),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
_recordStatusResult(false); _recordStatusResult(false);
+14 -3
View File
@@ -7,10 +7,10 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart'; import '../services/linux_ble_error_classifier.dart';
import '../services/notification_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
@@ -44,6 +44,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
isCurrentRoute && isCurrentRoute &&
!_changedNavigation) { !_changedNavigation) {
_changedNavigation = true; _changedNavigation = true;
// Prompt for notification permission on first
// connect so notifications work out of the box
// on Android 13+.
NotificationService().requestPermissions();
if (mounted) { if (mounted) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ContactsScreen()), MaterialPageRoute(builder: (context) => const ContactsScreen()),
@@ -54,6 +58,12 @@ class _ScannerScreenState extends State<ScannerScreen> {
_connector.addListener(_connectionListener); _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( _bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
(state) { (state) {
if (mounted) { if (mounted) {
@@ -318,10 +328,11 @@ class _ScannerScreenState extends State<ScannerScreen> {
return; return;
} }
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.scanner_connectionFailed(e.toString())), content: Text(context.l10n.scanner_connectionFailed(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
+36 -50
View File
@@ -11,7 +11,6 @@ import '../l10n/l10n.dart';
import '../models/radio_settings.dart'; import '../models/radio_settings.dart';
import '../services/app_debug_log_service.dart'; import '../services/app_debug_log_service.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../helpers/snack_bar_builder.dart';
import 'app_settings_screen.dart'; import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart'; import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart'; import 'ble_debug_log_screen.dart';
@@ -514,9 +513,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.setNodeName(controller.text); await connector.setNodeName(controller.text);
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_nodeNameUpdated)),
content: Text(l10n.settings_nodeNameUpdated),
); );
}, },
child: Text(l10n.common_save), child: Text(l10n.common_save),
@@ -630,9 +628,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
final interval = int.tryParse(intervalText); final interval = int.tryParse(intervalText);
if (interval == null || interval < 60 || interval >= 86400) { if (interval == null || interval < 60 || interval >= 86400) {
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.settings_locationIntervalInvalid), content: Text(l10n.settings_locationIntervalInvalid),
),
); );
return; return;
} }
@@ -640,9 +639,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.setCustomVar("gps_interval:$interval"); await connector.setCustomVar("gps_interval:$interval");
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_locationUpdated)),
content: Text(l10n.settings_locationUpdated),
); );
} }
@@ -662,17 +660,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
: currentLon; : currentLon;
if (lat == null || lon == null) { if (lat == null || lon == null) {
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_locationBothRequired)),
content: Text(l10n.settings_locationBothRequired),
); );
return; return;
} }
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_locationInvalid)),
content: Text(l10n.settings_locationInvalid),
); );
return; return;
} }
@@ -680,9 +676,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.setNodeLocation(lat: lat, lon: lon); await connector.setNodeLocation(lat: lat, lon: lon);
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_locationUpdated)),
content: Text(l10n.settings_locationUpdated),
); );
}, },
child: Text(l10n.common_save), child: Text(l10n.common_save),
@@ -696,10 +691,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _syncTime(BuildContext context, MeshCoreConnector connector) { void _syncTime(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n; final l10n = context.l10n;
connector.syncTime(); connector.syncTime();
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(l10n.settings_timeSynchronized), ).showSnackBar(SnackBar(content: Text(l10n.settings_timeSynchronized)));
);
} }
void _confirmReboot(BuildContext context, MeshCoreConnector connector) { void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
@@ -764,27 +758,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!mounted) return; if (!mounted) return;
switch (result) { switch (result) {
case gpxExportSuccess: case gpxExportSuccess:
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(l10n.settings_gpxExportSuccess), ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportSuccess)));
);
case gpxExportNoContacts: case gpxExportNoContacts:
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_gpxExportNoContacts)),
content: Text(l10n.settings_gpxExportNoContacts),
); );
break; break;
case gpxExportNotAvailable: case gpxExportNotAvailable:
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_gpxExportNotAvailable)),
content: Text(l10n.settings_gpxExportNotAvailable),
); );
break; break;
case gpxExportFailed: case gpxExportFailed:
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(l10n.settings_gpxExportError), ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportError)));
);
break; break;
} }
} }
@@ -1087,9 +1077,8 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
); );
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_telemetryModeUpdated)),
content: Text(l10n.settings_telemetryModeUpdated),
); );
}, },
child: Text(l10n.common_save), child: Text(l10n.common_save),
@@ -1421,18 +1410,18 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
final txPower = int.tryParse(_txPowerController.text); final txPower = int.tryParse(_txPowerController.text);
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) { if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
showDismissibleSnackBar( ScaffoldMessenger.of(
context, context,
content: Text(l10n.settings_frequencyInvalid), ).showSnackBar(SnackBar(content: Text(l10n.settings_frequencyInvalid)));
);
return; return;
} }
final maxTxPower = widget.connector.maxTxPower ?? 22; final maxTxPower = widget.connector.maxTxPower ?? 22;
if (txPower == null || txPower < 0 || txPower > maxTxPower) { if (txPower == null || txPower < 0 || txPower > maxTxPower) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'), content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
),
); );
return; return;
} }
@@ -1452,9 +1441,8 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
if (knownRepeat) { if (knownRepeat) {
const validRepeatFreqsKHz = {433000, 869000, 918000}; const validRepeatFreqsKHz = {433000, 869000, 918000};
if (_clientRepeat && !validRepeatFreqsKHz.contains(freqHz)) { if (_clientRepeat && !validRepeatFreqsKHz.contains(freqHz)) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_clientRepeatFreqWarning)),
content: Text(l10n.settings_clientRepeatFreqWarning),
); );
return; return;
} }
@@ -1484,16 +1472,14 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
if (!mounted) return; if (!mounted) return;
_logRadioSettingsState('Radio settings saved successfully'); _logRadioSettingsState('Radio settings saved successfully');
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_radioSettingsUpdated)),
content: Text(l10n.settings_radioSettingsUpdated),
); );
} catch (e) { } catch (e) {
_appLog.warn('Radio settings save failed: $e', tag: 'RadioSettings'); _appLog.warn('Radio settings save failed: $e', tag: 'RadioSettings');
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(l10n.settings_error(e.toString()))),
content: Text(l10n.settings_error(e.toString())),
); );
} }
Navigator.pop(context); Navigator.pop(context);
+2 -5
View File
@@ -8,7 +8,6 @@ import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
@@ -271,10 +270,8 @@ class _TcpScreenState extends State<TcpScreen> {
void _showError(String message) { void _showError(String message) {
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(message), backgroundColor: Colors.red),
content: Text(message),
backgroundColor: Colors.red,
); );
} }
+9 -7
View File
@@ -14,7 +14,6 @@ import '../utils/app_logger.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
import '../helpers/cayenne_lpp.dart'; import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart'; import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart';
class TelemetryScreen extends StatefulWidget { class TelemetryScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@@ -87,10 +86,11 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_isLoading = false; _isLoading = false;
_isLoaded = false; _isLoaded = false;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.telemetry_requestTimeout), content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
_recordTelemetryResult(false); _recordTelemetryResult(false);
}); });
@@ -137,10 +137,11 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_parsedTelemetry = parsedTelemetry; _parsedTelemetry = parsedTelemetry;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.telemetry_receivedData), content: Text(context.l10n.telemetry_receivedData),
backgroundColor: Colors.green, backgroundColor: Colors.green,
),
); );
_statusTimeout?.cancel(); _statusTimeout?.cancel();
if (!mounted) return; if (!mounted) return;
@@ -181,10 +182,11 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_isLoaded = false; _isLoaded = false;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.telemetry_errorLoading(e.toString())), content: Text(context.l10n.telemetry_errorLoading(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
+3 -3
View File
@@ -10,7 +10,6 @@ import '../utils/app_logger.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart'; import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'scanner_screen.dart'; import 'scanner_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
@@ -384,10 +383,11 @@ class _UsbScreenState extends State<UsbScreen> {
void _showError(Object error) { void _showError(Object error) {
if (!mounted) return; if (!mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(_friendlyErrorMessage(error)), content: Text(_friendlyErrorMessage(error)),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
+90 -8
View File
@@ -1,11 +1,30 @@
import 'package:flutter/widgets.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart';
class BackgroundService { /// 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 {
bool _initialized = false; 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 { Future<void> initialize() async {
if (!PlatformInfo.isAndroid || _initialized) return; if (_initialized) return;
// Register for app lifecycle events on all mobile platforms.
WidgetsBinding.instance.addObserver(this);
if (PlatformInfo.isAndroid) {
FlutterForegroundTask.init( FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions( androidNotificationOptions: AndroidNotificationOptions(
channelId: 'meshcore_background', channelId: 'meshcore_background',
@@ -24,30 +43,78 @@ class BackgroundService {
allowWifiLock: false, allowWifiLock: false,
), ),
); );
}
_initialized = true; _initialized = true;
} }
Future<void> start() async { Future<void> start() async {
if (!PlatformInfo.isAndroid) return; if (!PlatformInfo.isMobile) return;
if (!_initialized) { if (!_initialized) {
await initialize(); 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; final running = await FlutterForegroundTask.isRunningService;
if (running) return; if (!running) {
await FlutterForegroundTask.startService( await FlutterForegroundTask.startService(
notificationTitle: 'MeshCore running', notificationTitle: 'MeshCore running',
notificationText: 'Keeping BLE connected', notificationText: 'Keeping BLE connected',
callback: startCallback, 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 { Future<void> stop() async {
if (!PlatformInfo.isAndroid) return; if (!PlatformInfo.isMobile) return;
if (PlatformInfo.isAndroid) {
final running = await FlutterForegroundTask.isRunningService; final running = await FlutterForegroundTask.isRunningService;
if (!running) return; if (running) {
await FlutterForegroundTask.stopService(); 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') @pragma('vm:entry-point')
void startCallback() { void startCallback() {
@@ -56,10 +123,25 @@ void startCallback() {
class _MeshCoreTaskHandler extends TaskHandler { class _MeshCoreTaskHandler extends TaskHandler {
@override @override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {} 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.
}
@override @override
void onRepeatEvent(DateTime timestamp) {} 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')}',
);
}
@override @override
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {} Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
+186 -38
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io' show Platform, File; import 'dart:io' show Platform, File;
import 'dart:ui'; import 'dart:ui';
@@ -8,6 +9,21 @@ import '../helpers/reaction_helper.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/platform_info.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 { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance; factory NotificationService() => _instance;
@@ -17,6 +33,15 @@ class NotificationService {
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
bool _isInitialized = false; 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 for localized notification strings
Locale _locale = const Locale('en'); Locale _locale = const Locale('en');
@@ -167,6 +192,10 @@ class NotificationService {
}) async { }) async {
if (!await _ensureInitialized()) return; 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( final androidDetails = AndroidNotificationDetails(
'messages', 'messages',
'Messages', 'Messages',
@@ -175,6 +204,7 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
icon: '@mipmap/ic_launcher', icon: '@mipmap/ic_launcher',
number: badgeCount, number: badgeCount,
groupKey: groupKey,
); );
final iosDetails = DarwinNotificationDetails( final iosDetails = DarwinNotificationDetails(
@@ -205,6 +235,13 @@ class NotificationService {
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
payload: 'message:$contactId', payload: 'message:$contactId',
); );
await _postGroupSummary(
groupKey: groupKey,
channelId: 'messages',
channelName: 'Messages',
title: contactName,
payload: 'message:$contactId',
);
} catch (e) { } catch (e) {
debugPrint('Failed to show message notification: $e'); debugPrint('Failed to show message notification: $e');
} }
@@ -217,6 +254,8 @@ class NotificationService {
}) async { }) async {
if (!await _ensureInitialized()) return; if (!await _ensureInitialized()) return;
const groupKey = 'meshcore_adverts';
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
'adverts', 'adverts',
'Advertisements', 'Advertisements',
@@ -224,6 +263,7 @@ class NotificationService {
importance: Importance.defaultImportance, importance: Importance.defaultImportance,
priority: Priority.defaultPriority, priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher', icon: '@mipmap/ic_launcher',
groupKey: groupKey,
); );
const iosDetails = DarwinNotificationDetails( const iosDetails = DarwinNotificationDetails(
@@ -254,6 +294,15 @@ class NotificationService {
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
payload: 'advert:$contactId', 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) { } catch (e) {
debugPrint('Failed to show advert notification: $e'); debugPrint('Failed to show advert notification: $e');
} }
@@ -267,6 +316,12 @@ class NotificationService {
}) async { }) async {
if (!await _ensureInitialized()) return; 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( final androidDetails = AndroidNotificationDetails(
'channel_messages', 'channel_messages',
'Channel Messages', 'Channel Messages',
@@ -275,6 +330,7 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
icon: '@mipmap/ic_launcher', icon: '@mipmap/ic_launcher',
number: badgeCount, number: badgeCount,
groupKey: groupKey,
); );
final iosDetails = DarwinNotificationDetails( final iosDetails = DarwinNotificationDetails(
@@ -310,11 +366,70 @@ class NotificationService {
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
payload: 'channel:$channelIndex', payload: 'channel:$channelIndex',
); );
await _postGroupSummary(
groupKey: groupKey,
channelId: 'channel_messages',
channelName: 'Channel Messages',
title: channelName,
payload: 'channel:$channelIndex',
);
} catch (e) { } catch (e) {
debugPrint('Failed to show channel notification: $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. /// Returns a privacy-safe identifier for debug logging.
/// - advert: shows device name (body contains contactName) /// - advert: shows device name (body contains contactName)
/// - message: shows "from: sender" (avoids logging message content) /// - message: shows "from: sender" (avoids logging message content)
@@ -332,14 +447,42 @@ class NotificationService {
void _onNotificationTapped(NotificationResponse response) { void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload; final payload = response.payload;
if (payload != null) { if (payload == null) return;
debugPrint('Notification tapped: $payload'); debugPrint('Notification tapped: $payload');
// Handle navigation based on payload
// This can be extended to navigate to specific screens 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),
);
} }
} }
Future<void> cancelAll() async { Future<void> cancelAll() async {
_pendingNotifications.clear();
await _notifications.cancelAll(); await _notifications.cancelAll();
} }
@@ -352,6 +495,11 @@ class NotificationService {
String contactId, String contactId,
int totalUnreadCount, int totalUnreadCount,
) async { ) 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; if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode); await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount); await _updateBadge(totalUnreadCount);
@@ -362,6 +510,13 @@ class NotificationService {
int channelIndex, int channelIndex,
int totalUnreadCount, int totalUnreadCount,
) async { ) 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; if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode); await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount); await _updateBadge(totalUnreadCount);
@@ -375,6 +530,21 @@ 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 { Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) { if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update. // On Apple platforms, set the badge number directly via a silent update.
@@ -545,7 +715,13 @@ class NotificationService {
Future<void> _showBatchSummary(List<_PendingNotification> batch) async { Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!await _ensureInitialized()) return; if (!await _ensureInitialized()) return;
// Group by type // Show each notification individually the Android
// groupKey on each type will cluster them automatically.
for (final notification in batch) {
await _showNotificationImmediately(notification);
}
// Debug logging
final messages = batch final messages = batch
.where((n) => n.type == _NotificationType.message) .where((n) => n.type == _NotificationType.message)
.toList(); .toList();
@@ -556,48 +732,20 @@ class NotificationService {
.where((n) => n.type == _NotificationType.channelMessage) .where((n) => n.type == _NotificationType.channelMessage)
.toList(); .toList();
// Build summary text using localized plurals
final parts = <String>[]; final parts = <String>[];
if (messages.isNotEmpty) { if (messages.isNotEmpty) {
parts.add(_l10n.notification_messagesCount(messages.length)); parts.add('${messages.length} messages');
} }
if (channelMsgs.isNotEmpty) { if (channelMsgs.isNotEmpty) {
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length)); parts.add('${channelMsgs.length} channel msgs');
} }
if (adverts.isNotEmpty) { if (adverts.isNotEmpty) {
parts.add(_l10n.notification_newNodesCount(adverts.length)); parts.add('${adverts.length} adverts');
} }
debugPrint(
if (parts.isEmpty) return; '[Notification] batch dispatched: '
'${parts.join(", ")}',
// 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,42 +7,8 @@ class StorageService {
static const String _pathHistoryPrefix = 'path_history_'; static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages'; static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _repeaterAutoClockSyncAfterLoginKey =
'repeater_auto_clock_sync_after_login';
static const String _deliveryObservationsKey = 'delivery_observations'; 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( Future<void> savePathHistory(
String contactPubKeyHex, String contactPubKeyHex,
ContactPathHistory history, ContactPathHistory history,
+33
View File
@@ -0,0 +1,33 @@
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);
}
}
+18 -13
View File
@@ -11,7 +11,6 @@ import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../helpers/path_helper.dart'; import '../helpers/path_helper.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../helpers/snack_bar_builder.dart';
import 'path_selection_dialog.dart'; import 'path_selection_dialog.dart';
class PathManagementDialog { class PathManagementDialog {
@@ -66,10 +65,11 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
void _showFullPathDialog(BuildContext context, List<int> pathBytes) { void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
final l10n = context.l10n; final l10n = context.l10n;
if (pathBytes.isEmpty) { if (pathBytes.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.chat_pathDetailsNotAvailable), content: Text(l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
return; return;
} }
@@ -159,10 +159,11 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
); );
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.chat_hopsCount(result.length)), content: Text(l10n.chat_hopsCount(result.length)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
} }
} }
@@ -336,12 +337,13 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
_showFullPathDialog(context, path.pathBytes), _showFullPathDialog(context, path.pathBytes),
onTap: () async { onTap: () async {
if (path.pathBytes.isEmpty) { if (path.pathBytes.isEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
l10n.chat_pathDetailsNotAvailable, l10n.chat_pathDetailsNotAvailable,
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
return; return;
} }
@@ -359,12 +361,13 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
if (!context.mounted) return; if (!context.mounted) return;
Navigator.pop(context); Navigator.pop(context);
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text( content: Text(
l10n.path_usingHopsPath(path.hopCount), l10n.path_usingHopsPath(path.hopCount),
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
}, },
), ),
@@ -456,10 +459,11 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
onTap: () async { onTap: () async {
await connector.clearContactPath(currentContact); await connector.clearContactPath(currentContact);
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.chat_pathCleared), content: Text(l10n.chat_pathCleared),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
Navigator.pop(context); Navigator.pop(context);
}, },
@@ -485,10 +489,11 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
pathLen: -1, pathLen: -1,
); );
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.chat_floodModeEnabled), content: Text(l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
),
); );
Navigator.pop(context); Navigator.pop(context);
}, },
+9 -6
View File
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../helpers/snack_bar_builder.dart';
class PathSelectionDialog extends StatefulWidget { class PathSelectionDialog extends StatefulWidget {
final List<Contact> availableContacts; final List<Contact> availableContacts;
@@ -139,22 +138,26 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
// Show error for invalid prefixes // Show error for invalid prefixes
if (invalidPrefixes.isNotEmpty) { if (invalidPrefixes.isNotEmpty) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))), content: Text(
l10n.path_invalidHexPrefixes(invalidPrefixes.join(", ")),
),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
return; return;
} }
// Check max path length (64 hops) // Check max path length (64 hops)
if (pathBytesList.length > 64) { if (pathBytesList.length > 64) {
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(l10n.path_tooLong), content: Text(l10n.path_tooLong),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
return; return;
} }
+6 -34
View File
@@ -14,7 +14,7 @@ import 'path_management_dialog.dart';
class RepeaterLoginDialog extends StatefulWidget { class RepeaterLoginDialog extends StatefulWidget {
final Contact repeater; final Contact repeater;
final Function(String password, bool isAdmin) onLogin; final Function(String password) onLogin;
const RepeaterLoginDialog({ const RepeaterLoginDialog({
super.key, super.key,
@@ -119,7 +119,6 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
: '${selection.hopCount} hops'; : '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin'); appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin');
bool? loginResult; bool? loginResult;
bool isAdmin = false;
for (int attempt = 0; attempt < _maxAttempts; attempt++) { for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -132,7 +131,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
); );
await _connector.sendFrame(loginFrame); await _connector.sendFrame(loginFrame);
(loginResult, isAdmin) = await _awaitLoginResponse(timeout); loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) { if (loginResult == true) {
appLogger.info( appLogger.info(
'Login succeeded for ${repeater.name}', 'Login succeeded for ${repeater.name}',
@@ -188,32 +187,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex); 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) { if (mounted) {
Navigator.pop(context, password); Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password, isAdmin)); Future.microtask(() => widget.onLogin(password));
} }
} catch (e) { } catch (e) {
final repeater = _resolveRepeater(_connector); final repeater = _resolveRepeater(_connector);
@@ -230,21 +206,17 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
} }
} }
// _awaitLoginResponse returns a record of bool, for success and if the client is an admin Future<bool?> _awaitLoginResponse(Duration timeout) async {
Future<(bool?, bool)> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>(); final completer = Completer<bool?>();
Timer? timer; Timer? timer;
StreamSubscription<Uint8List>? subscription; StreamSubscription<Uint8List>? subscription;
final targetPrefix = widget.repeater.publicKey.sublist(0, 6); final targetPrefix = widget.repeater.publicKey.sublist(0, 6);
bool isAdmin = false;
subscription = _connector.receivedFrames.listen((frame) { subscription = _connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
final code = frame[0]; final code = frame[0];
if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return; if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return;
if (frame.length < 8) 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); final prefix = frame.sublist(2, 8);
if (!listEquals(prefix, targetPrefix)) return; if (!listEquals(prefix, targetPrefix)) return;
@@ -263,7 +235,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
final result = await completer.future; final result = await completer.future;
timer.cancel(); timer.cancel();
await subscription.cancel(); await subscription.cancel();
return (result, isAdmin); return result;
} }
@override @override
+8 -13
View File
@@ -10,12 +10,11 @@ import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../helpers/snack_bar_builder.dart';
import 'path_management_dialog.dart'; import 'path_management_dialog.dart';
class RoomLoginDialog extends StatefulWidget { class RoomLoginDialog extends StatefulWidget {
final Contact room; final Contact room;
final Function(String password, bool isAdmin) onLogin; final Function(String password) onLogin;
const RoomLoginDialog({super.key, required this.room, required this.onLogin}); const RoomLoginDialog({super.key, required this.room, required this.onLogin});
@@ -115,7 +114,6 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
: '${selection.hopCount} hops'; : '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin'); appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin');
bool? loginResult; bool? loginResult;
bool isAdmin = false;
for (int attempt = 0; attempt < _maxAttempts; attempt++) { for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -128,7 +126,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
); );
await _connector.sendFrame(loginFrame); await _connector.sendFrame(loginFrame);
(loginResult, isAdmin) = await _awaitLoginResponse(timeout); loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) { if (loginResult == true) {
appLogger.info('Login succeeded for ${room.name}', tag: 'RoomLogin'); appLogger.info('Login succeeded for ${room.name}', tag: 'RoomLogin');
break; break;
@@ -168,7 +166,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
if (mounted) { if (mounted) {
Navigator.pop(context, password); Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password, isAdmin)); Future.microtask(() => widget.onLogin(password));
} }
} catch (e) { } catch (e) {
final room = _resolveRepeater(_connector); final room = _resolveRepeater(_connector);
@@ -177,29 +175,26 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
setState(() { setState(() {
_isLoggingIn = false; _isLoggingIn = false;
}); });
showDismissibleSnackBar( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
content: Text(context.l10n.login_failed(e.toString())), content: Text(context.l10n.login_failed(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
),
); );
} }
} }
} }
Future<(bool?, bool)> _awaitLoginResponse(Duration timeout) async { Future<bool?> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>(); final completer = Completer<bool?>();
Timer? timer; Timer? timer;
StreamSubscription<Uint8List>? subscription; StreamSubscription<Uint8List>? subscription;
final targetPrefix = widget.room.publicKey.sublist(0, 6); final targetPrefix = widget.room.publicKey.sublist(0, 6);
bool isAdmin = false;
subscription = _connector.receivedFrames.listen((frame) { subscription = _connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
final code = frame[0]; final code = frame[0];
if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return; 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; if (frame.length < 8) return;
final prefix = frame.sublist(2, 8); final prefix = frame.sublist(2, 8);
if (!listEquals(prefix, targetPrefix)) return; if (!listEquals(prefix, targetPrefix)) return;
@@ -219,7 +214,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
final result = await completer.future; final result = await completer.future;
timer.cancel(); timer.cancel();
await subscription.cancel(); await subscription.cancel();
return (result, isAdmin); return result;
} }
@override @override
+1
View File
@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial flserial
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 8.0.0+10 version: 7.0.0+9
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
+343 -159
View File
@@ -1,198 +1,382 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart'; import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/screens/scanner_screen.dart'; import 'package:meshcore_open/widgets/adaptive_app_bar_title.dart';
import 'package:meshcore_open/screens/tcp_screen.dart';
import 'package:meshcore_open/services/app_settings_service.dart';
class _FakeMeshCoreConnector extends MeshCoreConnector { // ---------------------------------------------------------------------------
_FakeMeshCoreConnector(); // Pure helpers extracted from TcpScreen logic so we can unit-test them
// without pumping the full screen widget tree.
// ---------------------------------------------------------------------------
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected; /// Mirrors the validation in `_TcpScreenState._connectTcp`.
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth; String? validateTcpInputs({required String host, required String portText}) {
String? initialEndpoint; if (host.trim().isEmpty) return 'hostRequired';
int connectTcpCalls = 0; final parsed = int.tryParse(portText.trim());
String? lastHost; if (parsed == null || parsed < 1 || parsed > 65535) return 'portInvalid';
int? lastPort; return null;
@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;
}
} }
Widget _buildTestApp({ /// Mirrors `_TcpScreenState._buildStatusBar` text selection.
required MeshCoreConnector connector, String tcpStatusText({
required Widget child, required MeshCoreConnectionState state,
Locale? locale, 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,
}) { }) {
return MultiProvider( if (isTcpConnected) return connectedTo(activeTcpEndpoint ?? 'TCP');
providers: [ if (state == MeshCoreConnectionState.connecting &&
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector), transport == MeshCoreTransportType.tcp) {
ChangeNotifierProvider<AppSettingsService>( return connectingTo(connectingEndpoint);
create: (_) => AppSettingsService(),
),
],
child: MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
} }
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,
}) {
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() { void main() {
testWidgets('TcpScreen uses localized TCP copy', (tester) async { // -- Validation -----------------------------------------------------------
final connector = _FakeMeshCoreConnector();
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( await tester.pumpWidget(
_buildTestApp( MaterialApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'), 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(); await tester.pumpAndSettle();
final context = tester.element(find.byType(TcpScreen)); expect(l10n.tcpScreenTitle, isNotEmpty);
final l10n = AppLocalizations.of(context); expect(l10n.tcpHostLabel, isNotEmpty);
expect(l10n.tcpPortLabel, isNotEmpty);
expect(find.text(l10n.tcpScreenTitle), findsOneWidget); expect(l10n.tcpStatus_notConnected, isNotEmpty);
expect(find.text(l10n.tcpHostLabel), findsOneWidget); expect(l10n.tcpErrorHostRequired, isNotEmpty);
expect(find.text(l10n.tcpPortLabel), findsOneWidget); expect(l10n.tcpErrorPortInvalid, isNotEmpty);
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget); 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'));
}); });
testWidgets('TcpScreen validation errors are localized', (tester) async { // -- Isolated widget: AdaptiveAppBarTitle overflow ------------------------
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget( testWidgets('AdaptiveAppBarTitle does not overflow with long text', (
_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, tester,
) async { ) async {
final connector = _FakeMeshCoreConnector(); await tester.binding.setSurfaceSize(const Size(320, 100));
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)); 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( await tester.pumpWidget(
_buildTestApp( const MaterialApp(
connector: connector, home: Scaffold(
child: const TcpScreen(), body: SizedBox(
locale: const Locale('en'), width: 200,
child: AdaptiveAppBarTitle(
'This is a very long title that would normally overflow',
),
),
),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect( expect(
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)), find.text('This is a very long title that would normally overflow'),
findsOneWidget, findsOneWidget,
); );
});
await tester.pumpWidget(const SizedBox.shrink()); // -- Isolated widget: status bar Row with FittedBox overflow --------------
await tester.pump(const Duration(milliseconds: 60));
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,
),
),
),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text(longText), findsOneWidget);
}); });
} }
+536 -228
View File
@@ -1,276 +1,584 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart'; import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/screens/scanner_screen.dart'; import 'package:meshcore_open/utils/usb_port_labels.dart';
import 'package:meshcore_open/screens/usb_screen.dart';
import 'package:meshcore_open/utils/platform_info.dart';
class _FakeMeshCoreConnector extends MeshCoreConnector { // ---------------------------------------------------------------------------
_FakeMeshCoreConnector({ // Pure helpers extracted from UsbScreen logic.
this.initialState = MeshCoreConnectionState.disconnected, // ---------------------------------------------------------------------------
List<String>? ports,
}) : _ports = ports ?? <String>[];
final MeshCoreConnectionState initialState; /// Mirrors `_UsbScreenState._buildStatusBar` text selection.
final List<String> _ports; ///
/// [isLoadingPorts] corresponds to the screen's `_isLoadingPorts` flag.
String? requestPortLabel; String usbStatusText({
String? fallbackDeviceName; required bool isLoadingPorts,
int connectUsbCalls = 0; required bool isUsbTransportConnected,
String? lastConnectPortName; required MeshCoreConnectionState state,
String? fakeActiveUsbPort; required MeshCoreTransportType transport,
String? fakeActiveUsbPortDisplayLabel; String? activeUsbPortDisplayLabel,
bool fakeUsbTransportConnected = false; // L10n strings passed directly so we don't need BuildContext.
Future<List<String>> Function()? listUsbPortsImpl; required String searching,
Future<void> Function({required String portName})? connectUsbImpl; required String Function(String) connectedTo,
required String disconnecting,
@override required String connecting,
MeshCoreConnectionState get state => initialState; required String notConnected,
@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,
}) { }) {
return ChangeNotifierProvider<MeshCoreConnector>.value( if (isLoadingPorts) return searching;
value: connector, if (isUsbTransportConnected) {
child: MaterialApp( switch (state) {
localizationsDelegates: AppLocalizations.localizationsDelegates, case MeshCoreConnectionState.connected:
supportedLocales: AppLocalizations.supportedLocales, return connectedTo(activeUsbPortDisplayLabel ?? 'USB');
home: child, 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() { void main() {
testWidgets('UsbScreen passes localized chooser label to connector', ( // -- Port name helpers (normalizeUsbPortName / friendlyUsbPortName) -------
tester,
) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget( group('USB port name parsing', () {
_buildTestApp(connector: connector, child: const UsbScreen()), test('normalizeUsbPortName extracts raw port before separator', () {
); expect(normalizeUsbPortName('COM6 - USB Serial Device (COM6)'), 'COM6');
await tester.pumpAndSettle();
expect(connector.requestPortLabel, 'Select a USB device');
}); });
testWidgets( test('normalizeUsbPortName returns input when no separator', () {
'UsbScreen does not call connectUsb when connector is not disconnected', expect(normalizeUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0');
(tester) async { });
final connector = _FakeMeshCoreConnector(
initialState: MeshCoreConnectionState.connected, test('normalizeUsbPortName trims whitespace', () {
ports: <String>['COM6 - USB Serial Device (COM6)'], expect(normalizeUsbPortName(' COM3 '), 'COM3');
});
test('friendlyUsbPortName extracts description field', () {
expect(
friendlyUsbPortName('COM6 - USB Serial Device (COM6) - HWID'),
'USB Serial Device (COM6)',
); );
});
await tester.pumpWidget( test(
_buildTestApp(connector: connector, child: const UsbScreen()), 'friendlyUsbPortName falls back to raw name if description is n/a',
); () {
await tester.pumpAndSettle(); expect(friendlyUsbPortName('COM6 - n/a'), 'COM6');
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', ( test('friendlyUsbPortName falls back when only one part', () {
tester, expect(friendlyUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0');
) async { });
final connector = _FakeMeshCoreConnector(
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, 1);
expect(connector.lastConnectPortName, 'COM6');
}); });
testWidgets('ScannerScreen USB action reflects platform support', ( // -- Connect guard --------------------------------------------------------
tester,
) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget( group('USB connect guard', () {
_buildTestApp(connector: connector, child: const ScannerScreen()), test('allows connect when disconnected', () {
);
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));
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
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));
});
testWidgets('UsbScreen 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)
..fakeUsbTransportConnected = true
..fakeActiveUsbPortDisplayLabel =
'/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final context = tester.element(find.byType(UsbScreen));
final l10n = AppLocalizations.of(context);
expect( expect(
find.text( shouldAllowUsbConnect(MeshCoreConnectionState.disconnected),
l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!), 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',
), ),
findsOneWidget, 'CONNECTED:COM6 - Device',
); );
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
}); });
group('Error Handling', () { test('connected USB with null label falls back to USB', () {
testWidgets('shows error SnackBar when listing ports fails', ( 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, tester,
) async { ) async {
final connector = _FakeMeshCoreConnector(); late AppLocalizations l10n;
connector.listUsbPortsImpl = () async {
throw PlatformException(
code: 'usb_permission_denied',
message: 'Permission denied',
);
};
await tester.pumpWidget( await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()), 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(); await tester.pumpAndSettle();
expect(find.text('USB permission was denied.'), findsOneWidget); 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);
}); });
testWidgets('connection failure shows SnackBar error', (tester) async { // -- Isolated widget: status bar Row with FittedBox overflow --------------
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');
};
testWidgets('USB status bar 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 /dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
const statusColor = Colors.green;
// Exact widget tree from _buildStatusBar in UsbScreen.
await tester.pumpWidget( await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()), 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,
),
),
),
),
],
),
),
),
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byType(ListTile).first); expect(tester.takeException(), isNull);
expect(find.text(longText), findsOneWidget);
});
// -- Isolated widget: bottom nav FittedBox overflow -----------------------
testWidgets('Bottom nav row with multiple FABs does not overflow at 320px', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 200));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Mirrors the bottomNavigationBar structure from ScannerScreen / UsbScreen
// with all possible buttons visible.
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'),
),
],
),
),
),
),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(connectAttempted, isTrue); 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', () {
expect( expect(
find.text('Another USB connection request is already in progress.'), describeWebUsbPort(vendorId: null, productId: null),
findsOneWidget, 'Choose USB Device',
);
});
test('known VID:PID uses knownUsbNames', () {
expect(
describeWebUsbPort(
vendorId: 0x1A86,
productId: 0x7523,
knownUsbNames: {'1a86:7523': 'CH340 Serial'},
),
'CH340 Serial (VID:1A86 PID:7523)',
);
});
test('unknown VID:PID uses fallback device name', () {
expect(
describeWebUsbPort(
vendorId: 0x1234,
productId: 0x5678,
fallbackDeviceName: 'My Device',
),
'My Device (VID:1234 PID:5678)',
);
});
});
// -- buildUsbDisplayLabel -------------------------------------------------
group('buildUsbDisplayLabel', () {
test('appends device name when present', () {
expect(
buildUsbDisplayLabel(
basePortLabel: 'COM6',
deviceName: 'MeshCore Node',
),
'COM6 - MeshCore Node',
);
});
test('returns base label when device name is null', () {
expect(
buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: null),
'COM6',
);
});
test('returns base label when device name is whitespace', () {
expect(
buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: ' '),
'COM6',
); );
}); });
}); });
+69 -1
View File
@@ -1 +1,69 @@
{} {
"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"
]
}
+1
View File
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial flserial
flutter_local_notifications_windows flutter_local_notifications_windows
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)